diff --git a/Pipfile b/Pipfile index c32b29c3..be6dd759 100644 --- a/Pipfile +++ b/Pipfile @@ -12,9 +12,11 @@ pytest = ">=2.8.7" pytest-cov = ">=2.0.0" pytest-django = ">=2.9.1" pytest-filedata = ">=0.1.0" +pytest-mock = ">=3.3.1" jsonschema = ">=2.6.0" facsimile = ">=1.1.1" "twentyc.rpc" = ">=0.3.5,<0.5" +black = "==19.10b0" [packages] # core requirements diff --git a/Pipfile.lock b/Pipfile.lock index 75124ce5..be75513e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b2812edd3c3e2fb48806234e56469263ca2520f1f519f0120abec843701da895" + "sha256": "f5b0f8496271450373080050ce0aeab8f6ac3da708da2f2fd08cede9e060a140" }, "pipfile-spec": 6, "requires": { @@ -21,15 +21,16 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bleach": { "hashes": [ - "sha256:2bce3d8fab545a6528c8fa5d9f9ae8ebc85a56da365c7f85180bfe96a35ef22f", - "sha256:3c4c520fdb9db59ef139915a5db79f8b51bc2a7257ea0389f30c846883430a4b" + "sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080", + "sha256:9f8ccbeb6183c6e6cddea37592dfb0167485c1e3b13b3363bc325aa8bda3adbd" ], "index": "pypi", - "version": "==3.1.5" + "version": "==3.2.1" }, "certifi": { "hashes": [ @@ -57,6 +58,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "coreapi": { @@ -79,15 +81,16 @@ "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.6.0" }, "django": { "hashes": [ - "sha256:edf0ecf6657713b0435b6757e6069466925cae70d634a3283c96b80c01e06191", - "sha256:f2250bd35d0f6c23e930c544629934144e5dd39a4c06092e1050c731c1712ba8" + "sha256:62cf45e5ee425c52e411c0742e641a6588b7e8af0d2c274a27940931b2786594", + "sha256:83ced795a0f239f41d8ecabf51cc5fad4b97462a6008dc12e5af3cb9288724ec" ], "index": "pypi", - "version": "==2.2.14" + "version": "==2.2.16" }, "django-allauth": { "hashes": [ @@ -113,11 +116,11 @@ }, "django-cors-headers": { "hashes": [ - "sha256:5240062ef0b16668ce8a5f43324c388d65f5439e1a30e22c38684d5ddaff0d15", - "sha256:f5218f2f0bb1210563ff87687afbf10786e080d8494a248e705507ebd92d7153" + "sha256:9322255c296d5f75089571f29e520c83ff9693df17aa3cf9f6a4bea7c6740169", + "sha256:db82b2840f667d47872ae3e4a4e0a0d72fbecb42779b8aa233fa8bb965f7836a" ], "index": "pypi", - "version": "==3.4.0" + "version": "==3.5.0" }, "django-cors-middleware": { "hashes": [ @@ -129,11 +132,11 @@ }, "django-countries": { "hashes": [ - "sha256:2e852c9693818d64d28758a720a0cb8277673ac495af8b855c4dc64940703bd2", - "sha256:e2ae9b76f9a0b5f3f365b5b81fe4972df0a5878c930139586f43f7d6d50a9594" + "sha256:64015977a5989bcb0e645007299b19fe8ac117466af375161b26bcfa32ae2808", + "sha256:a0f77154ae08cb38a0d65530a399ead5f5837ebf6c74f7576e71bb7acdacca94" ], "index": "pypi", - "version": "==6.1.2" + "version": "==6.1.3" }, "django-crispy-forms": { "hashes": [ @@ -145,19 +148,19 @@ }, "django-debug-toolbar": { "hashes": [ - "sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943", - "sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c" + "sha256:a1ce0665f7ef47d27b8df4b5d1058748e1f08500a01421a30d35164f38aaaf4c", + "sha256:c97921a9cd421d392e7860dc4b464db8e06c8628df4dc58fedab012888c293c6" ], "index": "pypi", - "version": "==2.2" + "version": "==3.1.1" }, "django-extensions": { "hashes": [ - "sha256:6230898b1e1d5deb3ddab8335b2d270edb7afa4ef916a95e479a19fdfb0464cb", - "sha256:d5fcf8f3bab019487e07473c24453bccd5acfb4440f3ef36788294c307b09d4c" + "sha256:6809c89ca952f0e08d4e0766bc0101dfaf508d7649aced1180c091d737046ea7", + "sha256:dc663652ac9460fd06580a973576820430c6d428720e874ae46b041fa63e0efa" ], "index": "pypi", - "version": "==3.0.3" + "version": "==3.0.9" }, "django-formtools": { "hashes": [ @@ -212,10 +215,10 @@ }, "django-otp": { "hashes": [ - "sha256:97849f7bf1b50c4c36a5845ab4d2e11dd472fa8e6bcc34fe18b6d3af6e4aa449", - "sha256:d2390e61794bc10dea2fd949cbcfb7946e9ae4fb248df5494ccc4ef9ac50427e" + "sha256:50e54bc09bc435e2ad88f0aa7008718079c3529c422b469b3991a97d28b147bb", + "sha256:6b92c69021558765e80411479a01788977106d5696c391d2e5342074c1dd74d1" ], - "version": "==0.9.3" + "version": "==0.9.4" }, "django-peeringdb": { "hashes": [ @@ -256,14 +259,15 @@ }, "django-reversion": { "hashes": [ - "sha256:72fc53580a6b538f0cfff10f27f42333f67d79c406399289c94ec5a193cfb3e1", - "sha256:ecab4703ecc0871dc325c3e100139def84eb153622df3413fbcd9de7d3503c78" + "sha256:49e9930f90322dc6a2754dd26144285cfcc1c5bd0c1c39ca95d5602c5054ae32", + "sha256:9cfadeec2df37cb53d795ab79f6792f9eed8e70363dcf3a275dc19a58b971a8f" ], "index": "pypi", - "version": "==3.0.7" + "version": "==3.0.8" }, "django-simple-captcha": { "hashes": [ + "sha256:33f6a01fb8da1a2283bcc56db35cbb3fdc3f444157363ae25f0b48d111ca52de", "sha256:fc25f0425e282aa82d2a65013049a8dc7c0682f8e05d32681c39a0c55ed322bd" ], "index": "pypi", @@ -287,38 +291,40 @@ }, "django-vanilla-views": { "hashes": [ - "sha256:5cc887a28fa85ea49c143adde422ac5a6dffc7f224b65c87cc8705bd4b317f3f", - "sha256:efce79a8b769287136d62ff926b341f1493964fa81a206a402d755dca6d918ff" + "sha256:02bc0406b30546313c7edd6f3193e1a24c7fce9748bc1451d683824ce1b6c364", + "sha256:c65717fc940340d668b3f47d80bbe8565d63de9b1c089bb5d9482dbb2410a508" ], "index": "pypi", - "version": "==1.1.0" + "version": "==2.0.0" }, "djangorestframework": { "hashes": [ - "sha256:05809fc66e1c997fd9a32ea5730d9f4ba28b109b9da71fccfa5ff241201fd0a4", - "sha256:e782087823c47a26826ee5b6fa0c542968219263fb3976ec3c31edab23a4001f" + "sha256:6dd02d5a4bd2516fb93f80360673bf540c3b6641fec8766b1da2870a5aa00b32", + "sha256:8b1ac62c581dbc5799b03e535854b92fc4053ecfe74bad3f9c05782063d4196b" ], "index": "pypi", - "version": "==3.11.0" + "version": "==3.11.1" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "googlemaps": { "hashes": [ - "sha256:2ae3a8907f43c8069744bd72e210497d89f18f7f7472de96ea835b049603e395" + "sha256:8e13cbecc9b5b0462d53a78074c166753027be285db0c9fff70f5b40241ce500" ], "index": "pypi", - "version": "==4.4.1" + "version": "==4.4.2" }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "importlib-metadata": { @@ -341,6 +347,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "markdown": { @@ -387,6 +394,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "munge": { @@ -410,6 +418,7 @@ "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.0" }, "openapi-codec": { @@ -423,6 +432,7 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "passlib": { @@ -435,18 +445,18 @@ }, "peeringdb": { "hashes": [ - "sha256:bc6fcc74b87ea5ae2e554cc58ac46161f5ea4e27b3c422154701174c2b5091f2" + "sha256:927a34c31e5b93130a855bb4c8fd84dcf604b9939678f75918f7c1bd8a501471" ], "index": "pypi", - "version": "==1.0.0" + "version": "==1.1.0" }, "phonenumbers": { "hashes": [ - "sha256:d332078fe71c6153b5a263ac87283618b2afe514a248a14f06a0d39ce1f5ce0b", - "sha256:e49b8e21c557f0dafee966ddd55fb2bd3d6db155451999b75fb1b012e8d2016c" + "sha256:b8644c1dccd45d4c0f54c5b10effcc8c3f733e6a3c2caf40c9175fabc5010ffe", + "sha256:f887eceb3d9db17ec479a85245bd0ebe74c5f43489217784215ffb231f8c9e88" ], "index": "pypi", - "version": "==8.12.6" + "version": "==8.12.9" }, "pillow": { "hashes": [ @@ -467,16 +477,19 @@ "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4", "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626", "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d", + "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6", "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6", "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63", "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f", "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41", "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1", + "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117", "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d", "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9", "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a", "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce" ], + "markers": "python_version >= '3.5'", "version": "==7.2.0" }, "pyparsing": { @@ -484,6 +497,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "python3-openid": { @@ -535,41 +549,74 @@ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.24.0" }, "requests-oauthlib": { "hashes": [ "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" ], "version": "==1.3.0" }, "simplejson": { "hashes": [ - "sha256:0fe3994207485efb63d8f10a833ff31236ed27e3b23dadd0bf51c9900313f8f2", - "sha256:17163e643dbf125bb552de17c826b0161c68c970335d270e174363d19e7ea882", - "sha256:1d1e929cdd15151f3c0b2efe953b3281b2fd5ad5f234f77aca725f28486466f6", - "sha256:1ea59f570b9d4916ae5540a9181f9c978e16863383738b69a70363bc5e63c4cb", - "sha256:22a7acb81968a7c64eba7526af2cf566e7e2ded1cb5c83f0906b17ff1540f866", - "sha256:2b4b2b738b3b99819a17feaf118265d0753d5536049ea570b3c43b51c4701e81", - "sha256:4cf91aab51b02b3327c9d51897960c554f00891f9b31abd8a2f50fd4a0071ce8", - "sha256:7cce4bac7e0d66f3a080b80212c2238e063211fe327f98d764c6acbc214497fc", - "sha256:8027bd5f1e633eb61b8239994e6fc3aba0346e76294beac22a892eb8faa92ba1", - "sha256:86afc5b5cbd42d706efd33f280fec7bd7e2772ef54e3f34cf6b30777cd19a614", - "sha256:87d349517b572964350cc1adc5a31b493bbcee284505e81637d0174b2758ba17", - "sha256:926bcbef9eb60e798eabda9cd0bbcb0fca70d2779aa0aa56845749d973eb7ad5", - "sha256:9a126c3a91df5b1403e965ba63b304a50b53d8efc908a8c71545ed72535374a3", - "sha256:daaf4d11db982791be74b23ff4729af2c7da79316de0bebf880fa2d60bcc8c5a", - "sha256:fc046afda0ed8f5295212068266c92991ab1f4a50c6a7144b69364bdee4a0159", - "sha256:fc9051d249dd5512e541f20330a74592f7a65b2d62e18122ca89bf71f94db748" + "sha256:034550078a11664d77bc1a8364c90bb7eef0e44c2dbb1fd0a4d92e3997088667", + "sha256:05b43d568300c1cd43f95ff4bfcff984bc658aa001be91efb3bb21df9d6288d3", + "sha256:0dd9d9c738cb008bfc0862c9b8fa6743495c03a0ed543884bf92fb7d30f8d043", + "sha256:10fc250c3edea4abc15d930d77274ddb8df4803453dde7ad50c2f5565a18a4bb", + "sha256:2862beabfb9097a745a961426fe7daf66e1714151da8bb9a0c430dde3d59c7c0", + "sha256:292c2e3f53be314cc59853bd20a35bf1f965f3bc121e007ab6fd526ed412a85d", + "sha256:2d3eab2c3fe52007d703a26f71cf649a8c771fcdd949a3ae73041ba6797cfcf8", + "sha256:2e7b57c2c146f8e4dadf84977a83f7ee50da17c8861fd7faf694d55e3274784f", + "sha256:311f5dc2af07361725033b13cc3d0351de3da8bede3397d45650784c3f21fbcf", + "sha256:344e2d920a7f27b4023c087ab539877a1e39ce8e3e90b867e0bfa97829824748", + "sha256:3fabde09af43e0cbdee407555383063f8b45bfb52c361bc5da83fcffdb4fd278", + "sha256:42b8b8dd0799f78e067e2aaae97e60d58a8f63582939af60abce4c48631a0aa4", + "sha256:4b3442249d5e3893b90cb9f72c7d6ce4d2ea144d2c0d9f75b9ae1e5460f3121a", + "sha256:55d65f9cc1b733d85ef95ab11f559cce55c7649a2160da2ac7a078534da676c8", + "sha256:5c659a0efc80aaaba57fcd878855c8534ecb655a28ac8508885c50648e6e659d", + "sha256:72d8a3ffca19a901002d6b068cf746be85747571c6a7ba12cbcf427bfb4ed971", + "sha256:75ecc79f26d99222a084fbdd1ce5aad3ac3a8bd535cd9059528452da38b68841", + "sha256:76ac9605bf2f6d9b56abf6f9da9047a8782574ad3531c82eae774947ae99cc3f", + "sha256:7d276f69bfc8c7ba6c717ba8deaf28f9d3c8450ff0aa8713f5a3280e232be16b", + "sha256:7f10f8ba9c1b1430addc7dd385fc322e221559d3ae49b812aebf57470ce8de45", + "sha256:8042040af86a494a23c189b5aa0ea9433769cc029707833f261a79c98e3375f9", + "sha256:813846738277729d7db71b82176204abc7fdae2f566e2d9fcf874f9b6472e3e6", + "sha256:845a14f6deb124a3bcb98a62def067a67462a000e0508f256f9c18eff5847efc", + "sha256:869a183c8e44bc03be1b2bbcc9ec4338e37fa8557fc506bf6115887c1d3bb956", + "sha256:8acf76443cfb5c949b6e781c154278c059b09ac717d2757a830c869ba000cf8d", + "sha256:8f713ea65958ef40049b6c45c40c206ab363db9591ff5a49d89b448933fa5746", + "sha256:934115642c8ba9659b402c8bdbdedb48651fb94b576e3b3efd1ccb079609b04a", + "sha256:9551f23e09300a9a528f7af20e35c9f79686d46d646152a0c8fc41d2d074d9b0", + "sha256:9a2b7543559f8a1c9ed72724b549d8cc3515da7daf3e79813a15bdc4a769de25", + "sha256:a55c76254d7cf8d4494bc508e7abb993a82a192d0db4552421e5139235604625", + "sha256:ad8f41c2357b73bc9e8606d2fa226233bf4d55d85a8982ecdfd55823a6959995", + "sha256:af4868da7dd53296cd7630687161d53a7ebe2e63814234631445697bd7c29f46", + "sha256:afebfc3dd3520d37056f641969ce320b071bc7a0800639c71877b90d053e087f", + "sha256:b59aa298137ca74a744c1e6e22cfc0bf9dca3a2f41f51bc92eb05695155d905a", + "sha256:bc00d1210567a4cdd215ac6e17dc00cb9893ee521cee701adfd0fa43f7c73139", + "sha256:c1cb29b1fced01f97e6d5631c3edc2dadb424d1f4421dad079cb13fc97acb42f", + "sha256:c94dc64b1a389a416fc4218cd4799aa3756f25940cae33530a4f7f2f54f166da", + "sha256:ceaa28a5bce8a46a130cd223e895080e258a88d51bf6e8de2fc54a6ef7e38c34", + "sha256:cff6453e25204d3369c47b97dd34783ca820611bd334779d22192da23784194b", + "sha256:d0b64409df09edb4c365d95004775c988259efe9be39697d7315c42b7a5e7e94", + "sha256:d4813b30cb62d3b63ccc60dd12f2121780c7a3068db692daeb90f989877aaf04", + "sha256:da3c55cdc66cfc3fffb607db49a42448785ea2732f055ac1549b69dcb392663b", + "sha256:e058c7656c44fb494a11443191e381355388443d543f6fc1a245d5d238544396", + "sha256:fed0f22bf1313ff79c7fc318f7199d6c2f96d4de3234b2f12a1eab350e597c06", + "sha256:ffd4e4877a78c84d693e491b223385e0271278f5f4e1476a4962dca6824ecfeb" ], - "version": "==3.17.0" + "markers": "python_version >= '2.5' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.17.2" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sqlparse": { @@ -577,6 +624,7 @@ "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.3.1" }, "tld": { @@ -610,14 +658,16 @@ "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.1" }, "urllib3": { "hashes": [ - "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", - "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" ], - "version": "==1.25.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.25.10" }, "uwsgi": { "hashes": [ @@ -635,10 +685,11 @@ }, "zipp": { "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6", + "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f" ], - "version": "==3.1.0" + "markers": "python_version >= '3.6'", + "version": "==3.2.0" } }, "develop": { @@ -651,10 +702,19 @@ }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], - "version": "==19.3.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.2.0" + }, + "black": { + "hashes": [ + "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", + "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" + ], + "index": "pypi", + "version": "==19.10b0" }, "certifi": { "hashes": [ @@ -676,46 +736,48 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "coverage": { "hashes": [ - "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", - "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", - "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", - "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", - "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", - "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", - "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", - "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", - "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", - "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", - "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", - "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", - "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", - "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", - "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", - "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", - "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", - "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", - "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", - "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", - "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", - "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", - "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", - "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", - "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", - "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", - "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", - "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", - "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", - "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", - "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", - "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", - "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", - "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" + "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", + "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", + "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", + "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", + "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", + "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", + "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", + "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", + "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", + "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", + "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", + "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", + "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", + "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", + "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", + "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", + "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", + "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", + "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", + "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", + "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", + "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", + "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", + "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", + "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", + "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", + "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", + "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", + "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", + "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", + "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", + "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", + "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", + "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" ], - "version": "==5.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==5.3" }, "decorator": { "hashes": [ @@ -749,6 +811,7 @@ "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "idna": { @@ -756,6 +819,7 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "importlib-metadata": { @@ -766,6 +830,13 @@ "markers": "python_version < '3.8'", "version": "==1.7.0" }, + "iniconfig": { + "hashes": [ + "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", + "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" + ], + "version": "==1.0.1" + }, "jsonschema": { "hashes": [ "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", @@ -776,10 +847,11 @@ }, "more-itertools": { "hashes": [ - "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", - "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" + "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", + "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" ], - "version": "==8.4.0" + "markers": "python_version >= '3.5'", + "version": "==8.5.0" }, "munge": { "hashes": [ @@ -792,13 +864,22 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, + "pathspec": { + "hashes": [ + "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", + "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" + ], + "version": "==0.8.0" + }, "pluggy": { "hashes": [ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -806,6 +887,7 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -813,37 +895,39 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { "hashes": [ - "sha256:28669905fe725965daa16184933676547c5bb40a5153055a8dee2a4bd7933ad3" + "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" ], - "version": "==0.16.0" + "markers": "python_version >= '3.5'", + "version": "==0.17.3" }, "pytest": { "hashes": [ - "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1", - "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8" + "sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40", + "sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043" ], "index": "pypi", - "version": "==5.4.3" + "version": "==6.0.2" }, "pytest-cov": { "hashes": [ - "sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87", - "sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c" + "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", + "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" ], "index": "pypi", - "version": "==2.10.0" + "version": "==2.10.1" }, "pytest-django": { "hashes": [ - "sha256:64f99d565dd9497af412fcab2989fe40982c1282d4118ff422b407f3f7275ca5", - "sha256:664e5f42242e5e182519388f01b9f25d824a9feb7cd17d8f863c8d776f38baf9" + "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6", + "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4" ], "index": "pypi", - "version": "==3.9.0" + "version": "==3.10.0" }, "pytest-filedata": { "hashes": [ @@ -852,6 +936,14 @@ "index": "pypi", "version": "==0.4.0" }, + "pytest-mock": { + "hashes": [ + "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2", + "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82" + ], + "index": "pypi", + "version": "==3.3.1" + }, "pyyaml": { "hashes": [ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", @@ -868,11 +960,38 @@ ], "version": "==5.3.1" }, + "regex": { + "hashes": [ + "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204", + "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162", + "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f", + "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb", + "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6", + "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7", + "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88", + "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99", + "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644", + "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a", + "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840", + "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067", + "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd", + "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4", + "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e", + "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89", + "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e", + "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc", + "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf", + "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341", + "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7" + ], + "version": "==2020.7.14" + }, "requests": { "hashes": [ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.24.0" }, "requests-mock": { @@ -887,6 +1006,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "toml": { @@ -898,11 +1018,11 @@ }, "tox": { "hashes": [ - "sha256:1abf2ff7add9fabe95848af14cfe3c412361bef1ce52f7ceb4ff88d289e306ac", - "sha256:df4e418bee73acf258dcba1b1d9c6af0caea5d9105005ecf1b10a9a45c91dbdc" + "sha256:e6318f404aff16522ff5211c88cab82b39af121735a443674e4e2e65f4e4637b", + "sha256:eb629ddc60e8542fd4a1956b2462e3b8771d49f1ff630cecceacaa0fbfb7605a" ], "index": "pypi", - "version": "==3.17.0" + "version": "==3.20.0" }, "twentyc.rpc": { "hashes": [ @@ -916,33 +1036,55 @@ ], "version": "==0.2.0" }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "version": "==1.4.1" + }, "urllib3": { "hashes": [ - "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", - "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" ], - "version": "==1.25.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.25.10" }, "virtualenv": { "hashes": [ - "sha256:c11a475400e98450403c0364eb3a2d25d42f71cf1493da64390487b666de4324", - "sha256:e10cc66f40cbda459720dfe1d334c4dc15add0d80f09108224f171006a97a172" + "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc", + "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b" ], - "version": "==20.0.26" - }, - "wcwidth": { - "hashes": [ - "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", - "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" - ], - "version": "==0.2.5" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.0.31" }, "zipp": { "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6", + "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f" ], - "version": "==3.1.0" + "markers": "python_version >= '3.6'", + "version": "==3.2.0" } } } diff --git a/mainsite/settings/__init__.py b/mainsite/settings/__init__.py index 54ecd99a..e84c0d84 100644 --- a/mainsite/settings/__init__.py +++ b/mainsite/settings/__init__.py @@ -8,6 +8,77 @@ import django.conf.global_settings _DEFAULT_ARG = object() +def non_zipcode_countries(): + return { + "AE": "United Arab Emirates", + "AG": "Antigua and Barbuda", + "AN": "Netherlands Antilles", + "AO": "Angola", + "AW": "Aruba", + "BF": "Burkina Faso", + "BI": "Burundi", + "BJ": "Benin", + "BS": "Bahamas", + "BW": "Botswana", + "BZ": "Belize", + "CD": "Congo, the Democratic Republic of the", + "CF": "Central African Republic", + "CG": "Congo", + "CI": "Cote d'Ivoire", + "CK": "Cook Islands", + "CM": "Cameroon", + "DJ": "Djibouti", + "DM": "Dominica", + "ER": "Eritrea", + "FJ": "Fiji", + "GD": "Grenada", + "GH": "Ghana", + "GM": "Gambia", + "GN": "Guinea", + "GQ": "Equatorial Guinea", + "GY": "Guyana", + "HK": "Hong Kong", + "IE": "Ireland", + "JM": "Jamaica", + "KE": "Kenya", + "KI": "Kiribati", + "KM": "Comoros", + "KN": "Saint Kitts and Nevis", + "KP": "North Korea", + "LC": "Saint Lucia", + "ML": "Mali", + "MO": "Macao", + "MR": "Mauritania", + "MS": "Montserrat", + "MU": "Mauritius", + "MW": "Malawi", + "NR": "Nauru", + "NU": "Niue", + "PA": "Panama", + "QA": "Qatar", + "RW": "Rwanda", + "SB": "Solomon Islands", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SO": "Somalia", + "SR": "Suriname", + "ST": "Sao Tome and Principe", + "SY": "Syria", + "TF": "French Southern Territories", + "TK": "Tokelau", + "TL": "Timor-Leste", + "TO": "Tonga", + "TT": "Trinidad and Tobago", + "TV": "Tuvalu", + "TZ": "Tanzania, United Republic of", + "UG": "Uganda", + "VU": "Vanuatu", + "YE": "Yemen", + "ZA": "South Africa", + "ZW": "Zimbabwe", + } + + def print_debug(*args, **kwargs): if DEBUG: print(*args, **kwargs) @@ -166,6 +237,12 @@ set_option("DATA_QUALITY_MAX_PREFIXLEN_V6", 116) # maximum value to allow for irr set hierarchy depth set_option("DATA_QUALITY_MAX_IRR_DEPTH", 3) +# minimum value to allow for speed on an netixlan (currently 100Mbit) +set_option("DATA_QUALITY_MIN_SPEED", 100) + +# maximum value to allow for speed on an netixlan (currently 1Tbit) +set_option("DATA_QUALITY_MAX_SPEED", 1000000) + RATELIMITS = { "request_login_POST": "4/m", "request_translation": "2/m", @@ -186,6 +263,9 @@ MAX_USER_AFFILIATION_REQUESTS = 5 # during `pdb_delete_poc` execution. (days) set_option("POC_DELETION_PERIOD", 30) +# Sets maximum age for a user-request in the verification queue +# Otherwise we delete with the pdb_cleanup_vq tool +set_option("VQUEUE_USER_MAX_AGE", 90) # Django config @@ -246,7 +326,10 @@ LOGGING = { "class": "logging.handlers.WatchedFileHandler", "filename": os.path.join(BASE_DIR, "var/log/django.log"), }, - "console": {"level": "DEBUG", "class": "logging.StreamHandler",}, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + }, }, "loggers": { # Again, default Django configuration to email unhandled exceptions @@ -531,8 +614,16 @@ COUNTRIES_OVERRIDE = { set_option( "CLIENT_COMPAT", { - "client": {"min": (0,6), "max": (255,0),}, - "backends": {"django_peeringdb": {"min": (2,0,0,2), "max": (255,0),},}, + "client": { + "min": (0, 6), + "max": (255, 0), + }, + "backends": { + "django_peeringdb": { + "min": (2, 0, 0, 2), + "max": (255, 0), + }, + }, }, ) @@ -601,6 +692,9 @@ CSRF_FAILURE_VIEW = "peeringdb_server.views.view_http_error_csrf" RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" +# Set countries that don't use zipcodes +set_option("NON_ZIPCODE_COUNTRIES", non_zipcode_countries()) + ## Locale LANGUAGE_CODE = "en-us" @@ -651,13 +745,17 @@ if ENABLE_ALL_LANGUAGES: API_DOC_INCLUDES = {} API_DOC_PATH = os.path.join(BASE_DIR, "docs", "api") for _, _, files in os.walk(API_DOC_PATH): - for file in files: - base, ext = os.path.splitext(file) - if ext == ".md": - API_DOC_INCLUDES[base] = os.path.join(API_DOC_PATH, file) + for file in files: + base, ext = os.path.splitext(file) + if ext == ".md": + API_DOC_INCLUDES[base] = os.path.join(API_DOC_PATH, file) MAIL_DEBUG = DEBUG + +# Setting for automated resending of failed ixf import emails +set_option('IXF_RESEND_FAILED_EMAILS', False) + TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG if DEBUG: diff --git a/peeringdb_server/admin.py b/peeringdb_server/admin.py index 15e94936..0b36edbe 100644 --- a/peeringdb_server/admin.py +++ b/peeringdb_server/admin.py @@ -1564,7 +1564,15 @@ class CommandLineToolAdmin(admin.ModelAdmin): class IXFImportEmailAdmin(admin.ModelAdmin): - list_display = ("subject", "recipients", "created", "sent", "net", "ix") + list_display = ( + "subject", + "recipients", + "created", + "sent", + "net", + "ix", + "stale_info", + ) readonly_fields = ( "net", "ix", @@ -1572,6 +1580,15 @@ class IXFImportEmailAdmin(admin.ModelAdmin): search_fields = ("subject", "ix__name", "net__name") change_list_template = "admin/change_list_with_regex_search.html" + def stale_info(self, obj): + not_sent = obj.sent is None + if type(obj.sent) == datetime.datetime: + re_sent = (obj.sent - obj.created) > datetime.timedelta(minutes=5) + else: + re_sent = False + prod_mail_mode = not settings.MAIL_DEBUG + return prod_mail_mode and (not_sent or re_sent) + def get_search_results(self, request, queryset, search_term): queryset, use_distinct = super().get_search_results( request, queryset, search_term diff --git a/peeringdb_server/ixf.py b/peeringdb_server/ixf.py index 748b4d94..8213200b 100644 --- a/peeringdb_server/ixf.py +++ b/peeringdb_server/ixf.py @@ -4,13 +4,15 @@ import datetime import requests import ipaddress - +from smtplib import SMTPException from django.db import transaction from django.core.cache import cache -from django.utils.translation import ugettext_lazy as _ +from django.core.mail.message import EmailMultiAlternatives from django.core.exceptions import ValidationError -from django.template import loader from django.conf import settings +from django.template import loader +from django.utils.translation import ugettext_lazy as _ +from django.utils.html import strip_tags import reversion @@ -1029,9 +1031,9 @@ class Importer: for ixf_id, deletion in self.deletions.items(): if deletion.asn == ixf_member_data.asn: - if deletion.ipaddr4 == ixf_member_data.init_ipaddr4: + if deletion.ipaddr4 and deletion.ipaddr4 == ixf_member_data.init_ipaddr4: ip4_deletion = deletion - if deletion.ipaddr6 == ixf_member_data.init_ipaddr6: + if deletion.ipaddr6 and deletion.ipaddr6 == ixf_member_data.init_ipaddr6: ip6_deletion = deletion if ip4_deletion and ip6_deletion: @@ -1154,6 +1156,14 @@ class Importer: if netixlan: + # ixf-memberdata actions that are consolidated + # requirements of other actions should be kept + # out of the log as they are already implied by + # the log entry of the requirement (#824) + + if getattr(netixlan, "requirement_of", None): + return + if hasattr(netixlan, "network_id"): net_id = netixlan.network_id else: @@ -1174,6 +1184,7 @@ class Importer: "action": action, "reason": f"{reason}", } + self.log["data"].append(entry) if netixlan: @@ -1213,25 +1224,26 @@ class Importer: recipients=",".join(recipients), ix=ix, ) - if not self.notify_ix_enabled: return - if not getattr(settings, "MAIL_DEBUG", False): - mail = EmailMultiAlternatives( - subject, strip_tags(message), settings.DEFAULT_FROM_EMAIL, recipients, - ) - mail.send(fail_silently=False) - else: - self.emails += 1 - # debug_mail( - # subject, message, settings.DEFAULT_FROM_EMAIL, recipients, - # ) + self.emails += 1 + + prod_mail_mode = not getattr(settings, "MAIL_DEBUG", True) + if prod_mail_mode: + self._send_email(subject, strip_tags(message), recipients) + if email_log: + email_log.sent = datetime.datetime.now(datetime.timezone.utc) if email_log: - email_log.sent = datetime.datetime.now(datetime.timezone.utc) email_log.save() + def _send_email(self, subject, message, recipients): + mail = EmailMultiAlternatives( + subject, message, settings.DEFAULT_FROM_EMAIL, recipients, + ) + mail.send(fail_silently=False) + def _ticket(self, ixf_member_data, subject, message): """ @@ -1437,7 +1449,7 @@ class Importer: "ix": ix_notifications, } - def notify_proposals(self): + def notify_proposals(self, error_handler=None): """ Sends all collected notification proposals @@ -1457,45 +1469,57 @@ class Importer: template = loader.get_template("email/notify-ixf-consolidated.txt") + errors = [] + for recipient in ["ix", "net"]: for other_entity, data in consolidated[recipient].items(): - contacts = data["contacts"] + try: + self._notify_proposal(recipient, data, ticket_days, template) + except Exception as exc: + if error_handler: + error_handler(exc) + else: + raise - # we did not find any suitable contact points - # skip - if not contacts: - continue + def _notify_proposal(self, recipient, data, ticket_days, template): + contacts = data["contacts"] - # no messages + # we did not find any suitable contact points + # skip - if not data["count"]: - continue + if not contacts: + return - # render the consolidated message + # no messages - message = template.render( - { - "recipient": recipient, - "entity": data["entity"], - "count": data["count"], - "ticket_days": ticket_days, - "proposals": data["proposals"], - } - ) + if not data["count"]: + return - if recipient == "net": - subject = _( - "PeeringDB: Action May Be Needed: IX-F Importer " - "data mismatch between AS{} and one or more IXPs" - ).format(data["entity"].asn) - self._email(subject, message, contacts, net=data["entity"]) - else: - subject = _( - "PeeringDB: Action May Be Needed: IX-F Importer " - "data mismatch between {} and one or more networks" - ).format(data["entity"].name) - self._email(subject, message, contacts, ix=data["entity"]) + # render the consolidated message + + message = template.render( + { + "recipient": recipient, + "entity": data["entity"], + "count": data["count"], + "ticket_days": ticket_days, + "proposals": data["proposals"], + } + ) + + if recipient == "net": + subject = _( + "PeeringDB: Action May Be Needed: IX-F Importer " + "data mismatch between AS{} and one or more IXPs" + ).format(data["entity"].asn) + self._email(subject, message, contacts, net=data["entity"]) + else: + subject = _( + "PeeringDB: Action May Be Needed: IX-F Importer " + "data mismatch between {} and one or more networks" + ).format(data["entity"].name) + self._email(subject, message, contacts, ix=data["entity"]) def ticket_aged_proposals(self): @@ -1533,6 +1557,9 @@ class Importer: action = ixf_member_data.action if action == "delete": action = "remove" + elif action == "noop": + continue + typ = action # create the ticket @@ -1656,6 +1683,55 @@ class Importer: if save: self.save_log() + def resend_emails(self): + """ + Resend emails that weren't sent. + """ + + resent_emails = [] + for email in self.emails_to_resend: + try: + resent_email = self._resend_email(email) + if resent_email: + resent_emails.append(resent_email) + except SMTPException as email_exception: + pass + + return resent_emails + + @property + def emails_to_resend(self): + return IXFImportEmail.objects.filter(sent__isnull=True).all() + + def _resend_email(self, email): + + subject = email.subject + message = email.message + resend_str = "This email could not be delivered initially and may contain stale information. \n" + if not message.startswith(resend_str): + message = resend_str + message + + recipients = email.recipients.split(",") + + if email.net and not self.notify_net_enabled: + return False + + if email.ix and not self.notify_ix_enabled: + return False + + prod_mail_mode = not getattr(settings, "MAIL_DEBUG", True) + prod_resend_mode = getattr(settings, "IXF_RESEND_FAILED_EMAILS", False) + + if prod_mail_mode and prod_resend_mode: + self._send_email(subject, strip_tags(message), recipients) + email.sent = datetime.datetime.now(datetime.timezone.utc) + email.message = message + email.save() + else: + return False + + return email + class PostMortem: diff --git a/peeringdb_server/management/commands/pdb_api_test.py b/peeringdb_server/management/commands/pdb_api_test.py index ad5ae6f9..ee9e418a 100644 --- a/peeringdb_server/management/commands/pdb_api_test.py +++ b/peeringdb_server/management/commands/pdb_api_test.py @@ -571,6 +571,7 @@ class TestJSON(unittest.TestCase): with pytest.raises(InvalidRequestException) as excinfo: r = db.create(typ, data_invalid, return_response=True) + assert "400 Bad Request" in str(excinfo.value) # we test fail because of parent entity status @@ -1161,7 +1162,10 @@ class TestJSON(unittest.TestCase): "fac", data, test_failures={ - "invalid": {"name": "",}, + "invalid": [ + {"name": ""}, + {"name": self.make_name("Test"), "website": ""}, + ], "perms": { # need to set name again so it doesnt fail unique validation "name": self.make_name("Test"), @@ -1222,6 +1226,30 @@ class TestJSON(unittest.TestCase): ########################################################################## + def test_org_admin_002_POST_PUT_DELETE_fac_zipcode(self): + data = self.make_data_fac() + + # Requires a zipcode if country is a country + # with postal codes (ie US) + r_data = self.assert_create( + self.db_org_admin, + "fac", + data, + test_failures={ + "invalid": [{"name": self.make_name("Test"), "zipcode": ""},], + }, + test_success=False, + ) + + # Change to country w/o postal codes + data["country"] = "ZW" + data["zipcode"] = "" + + r_data = self.assert_create(self.db_org_admin, "fac", data,) + assert r_data["zipcode"] == "" + + ########################################################################## + def test_org_admin_002_POST_PUT_DELETE_net(self): data = self.make_data_net(asn=9000900) @@ -1647,6 +1675,49 @@ class TestJSON(unittest.TestCase): ########################################################################## + def test_org_admin_002_POST_netixlan_no_net_contact(self): + network = SHARED["net_rw_ok"] + + for poc in network.poc_set_active.all(): + poc.delete() + + data = self.make_data_netixlan( + net_id=SHARED["net_rw_ok"].id, + ixlan_id=SHARED["ixlan_rw_ok"].id, + asn=SHARED["net_rw_ok"].asn, + ) + + # When we create this netixlan it should fail with a + # non-field-error. + + r_data = self.assert_create( + self.db_org_admin, + "netixlan", + data, + test_failures={"invalid": {"n/a": "n/a"}}, + test_success=False, + ) + + # Undelete poc but blank email + poc = network.poc_set.first() + poc.status = "ok" + poc.email = "" + poc.visible = "Public" + poc.save() + network.refresh_from_db() + + # Also fails with network contact that is + # missing an email + r_data = self.assert_create( + self.db_org_admin, + "netixlan", + data, + test_failures={"invalid": {"n/a": "n/a"}}, + test_success=False, + ) + + ########################################################################## + def test_org_admin_002_POST_PUT_netixlan_validation(self): data = self.make_data_netixlan( net_id=SHARED["net_rw_ok"].id, ixlan_id=SHARED["ixlan_rw_ok"].id @@ -1657,6 +1728,12 @@ class TestJSON(unittest.TestCase): {"invalid": {"ipaddr4": self.get_ip4(SHARED["ixlan_r_ok"])}}, # test failure if ip6 not in prefix {"invalid": {"ipaddr6": self.get_ip6(SHARED["ixlan_r_ok"])}}, + # test failure if speed is below limit + {"invalid": {"speed": 1}}, + # test failure if speed is above limit + {"invalid": {"speed": 1250000}}, + # test failure if speed is None + {"invalid": {"speed": None}}, ] for test_failure in test_failures: @@ -1893,7 +1970,9 @@ class TestJSON(unittest.TestCase): with fields parameter set would raise a 500 error for unauthenticated users """ - data = self.db_guest.get("ixlan", SHARED["ixlan_rw_ok"].id, fields="ixpfx_set", depth=2) + data = self.db_guest.get( + "ixlan", SHARED["ixlan_rw_ok"].id, fields="ixpfx_set", depth=2 + ) assert len(data) == 1 row = data[0] assert list(row.keys()) == ["ixpfx_set"] diff --git a/peeringdb_server/management/commands/pdb_base_command.py b/peeringdb_server/management/commands/pdb_base_command.py new file mode 100644 index 00000000..446b5bad --- /dev/null +++ b/peeringdb_server/management/commands/pdb_base_command.py @@ -0,0 +1,17 @@ +from django.core.management.base import BaseCommand + +class PeeringDBBaseCommand(BaseCommand): + + def add_arguments(self, parser): + parser.add_argument( + "--commit", action="store_true", help="Commit the changes." + ) + + def log(self, msg): + if self.commit: + self.stdout.write(msg) + else: + self.stdout.write("[pretend] {}".format(msg)) + + def handle(self, *args, **options): + self.commit = options.get("commit") \ No newline at end of file diff --git a/peeringdb_server/management/commands/pdb_cleanup_vq.py b/peeringdb_server/management/commands/pdb_cleanup_vq.py new file mode 100644 index 00000000..fc21a05b --- /dev/null +++ b/peeringdb_server/management/commands/pdb_cleanup_vq.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta, timezone + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone + +from peeringdb_server.models import VerificationQueueItem, User +from peeringdb_server.management.commands.pdb_base_command import PeeringDBBaseCommand + +class Command(PeeringDBBaseCommand): + + help = "Use this tool to clean up the Verification Queue" + + def add_arguments(self, parser): + super().add_arguments(parser) + subparsers = parser.add_subparsers() + parser_users = subparsers.add_parser( + 'users', + parents=[parser], + add_help=False, + help='Tool to remove outdated user verification requests') + parser_users.set_defaults(func=self._clean_users) + + def handle(self, *args, **options): + super().handle(*args, **options) + + if options.get("func"): + options["func"]() + + def _clean_users(self): + model = User + content_type = ContentType.objects.get_for_model(model) + date = datetime.now(timezone.utc) - timedelta(days=settings.VQUEUE_USER_MAX_AGE) + + qset = VerificationQueueItem.objects.filter( + content_type=content_type, + created__lte=date + ) + count = qset.count() + + self.log(f"Deleting VerificationQueueItems for Users created before {date:%x}") + self.log(f"Items flagged for deletion: {count}") + + counter = 0 + + for vqi in qset: + counter += 1 + self.log(f"Deleting VerificationQueueItem for {vqi.content_type} " + f"id #{vqi.object_id}... {counter} / {count}") + + if self.commit: + vqi.delete() \ No newline at end of file diff --git a/peeringdb_server/management/commands/pdb_ixf_ixp_member_import.py b/peeringdb_server/management/commands/pdb_ixf_ixp_member_import.py index bdb740a3..de1012c7 100644 --- a/peeringdb_server/management/commands/pdb_ixf_ixp_member_import.py +++ b/peeringdb_server/management/commands/pdb_ixf_ixp_member_import.py @@ -1,5 +1,6 @@ import traceback import json +import sys from django.core.management.base import BaseCommand, CommandError from django.db import transaction @@ -75,11 +76,14 @@ class Command(BaseCommand): else: self.stdout.write("[Pretend] {}".format(msg)) - def release_env_check(self, flag): - if settings.RELEASE_ENV != "prod": - return True - else: - raise PermissionError("Flag {} is not permitted to be used in production.") + def store_runtime_error(self, error): + error_str = "ERROR: {}".format(error) + "\n" + error_str += traceback.format_exc() + self.runtime_errors.append(error_str) + + def write_runtime_errors(self): + for error in self.runtime_errors: + self.stderr.write(error) def initiate_reset_flags(self, **options): flags = [ @@ -161,6 +165,12 @@ class Command(BaseCommand): ), ) + def resend_emails(self, importer): + num_emails_to_resend = len(importer.emails_to_resend) + self.log(f"Attemping to resend {num_emails_to_resend} emails.") + resent_emails = importer.resend_emails() + self.log(f"RE-SENT EMAILS: {len(resent_emails)}.") + def handle(self, *args, **options): self.commit = options.get("commit", False) self.debug = options.get("debug", False) @@ -170,6 +180,8 @@ class Command(BaseCommand): self.active_reset_flags = self.initiate_reset_flags(**options) + self.runtime_errors = [] + if self.reset or self.reset_hints: self.reset_all_hints() if self.reset or self.reset_dismisses: @@ -237,8 +249,7 @@ class Command(BaseCommand): total_notifications += importer.notifications except Exception as inst: - self.log("ERROR: {}".format(inst)) - self.log(traceback.format_exc()) + self.store_runtime_error(inst) if self.preview: self.stdout.write(json.dumps(total_log, indent=2)) @@ -249,6 +260,13 @@ class Command(BaseCommand): importer = ixf.Importer() importer.reset(save=self.commit) importer.notifications = total_notifications - importer.notify_proposals() + importer.notify_proposals(error_handler=self.store_runtime_error) - self.stdout.write(f"Emails: {importer.emails}") + self.stdout.write(f"New Emails: {importer.emails}") + + if len(self.runtime_errors) > 0: + self.write_runtime_errors() + sys.exit(1) + + if self.commit: + self.resend_emails(importer) diff --git a/peeringdb_server/migrations/0053_alter_fac_website.py b/peeringdb_server/migrations/0053_alter_fac_website.py new file mode 100644 index 00000000..da6c8d47 --- /dev/null +++ b/peeringdb_server/migrations/0053_alter_fac_website.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.14 on 2020-09-08 21:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("peeringdb_server", "0052_deactivate_in_dfz"), + ] + + operations = [ + migrations.AlterField( + model_name="facility", + name="website", + field=models.URLField(verbose_name="Website"), + ), + ] diff --git a/peeringdb_server/models.py b/peeringdb_server/models.py index c62b7f54..02a1951d 100644 --- a/peeringdb_server/models.py +++ b/peeringdb_server/models.py @@ -1115,7 +1115,7 @@ class Facility(ProtectedMixin, pdb_models.FacilityBase, GeocodeBaseMixin): org = models.ForeignKey( Organization, on_delete=models.CASCADE, related_name="fac_set" ) - + website = models.URLField(_("Website"), blank=False) # FIXME: delete cascade needs to be fixed in django-peeringdb, can remove # this afterwards class HandleRef: @@ -4054,6 +4054,14 @@ class NetworkFacility(pdb_models.NetworkFacilityBase): # ip in prefix # prefix on lan # FIXME - need unique constraint at save time, allow empty string for ipv4/ipv6 +def format_speed(value): + if value >= 1000000: + return "%dT" % (value / 10 ** 6) + elif value >= 1000: + return "%dG" % (value / 10 ** 3) + else: + return "%dM" % value + @reversion.register class NetworkIXLan(pdb_models.NetworkIXLanBase): """ @@ -4205,6 +4213,21 @@ class NetworkIXLan(pdb_models.NetworkIXLanBase): if self.ipaddr6 and not self.ixlan.test_ipv6_address(self.ipaddr6): raise ValidationError(_("IPv6 address outside of prefix")) + def validate_speed(self): + if self.speed in [None, 0]: + pass + elif self.speed > settings.DATA_QUALITY_MAX_SPEED: + raise ValidationError(_("Maximum speed: {}").format( + format_speed(settings.DATA_QUALITY_MAX_SPEED) + ) + ) + elif self.speed < settings.DATA_QUALITY_MIN_SPEED: + raise ValidationError(_("Minimum speed: {}").format( + format_speed(settings.DATA_QUALITY_MIN_SPEED) + ) + ) + + def clean(self): """ Custom model validation @@ -4224,6 +4247,11 @@ class NetworkIXLan(pdb_models.NetworkIXLanBase): except ValidationError as exc: errors["ipaddr6"] = exc.message + try: + self.validate_speed() + except ValidationError as exc: + errors["speed"] = exc.message + if errors: raise ValidationError(errors) diff --git a/peeringdb_server/serializers.py b/peeringdb_server/serializers.py index 7d819b87..59ffca62 100644 --- a/peeringdb_server/serializers.py +++ b/peeringdb_server/serializers.py @@ -50,6 +50,7 @@ from peeringdb_server.validators import ( validate_prefix_overlap, validate_phonenumber, validate_irr_as_set, + validate_zipcode, ) from django.utils.translation import ugettext_lazy as _ @@ -1064,7 +1065,7 @@ class FacilitySerializer(ModelSerializer): website = serializers.URLField() address1 = serializers.CharField() city = serializers.CharField() - zipcode = serializers.CharField() + zipcode = serializers.CharField(required=False, allow_blank=True, default="") tech_phone = serializers.CharField(required=False, allow_blank=True, default="") sales_phone = serializers.CharField(required=False, allow_blank=True, default="") @@ -1185,6 +1186,11 @@ class FacilitySerializer(ModelSerializer): except ValidationError as exc: raise serializers.ValidationError({"sales_phone": exc.message}) + try: + data["zipcode"] = validate_zipcode(data["zipcode"], data["country"]) + except ValidationError as exc: + raise serializers.ValidationError({"zipcode": exc.message}) + return data @@ -1462,9 +1468,35 @@ class NetworkIXLanSerializer(ModelSerializer): pass return super().run_validation(data=data) - def validate(self, data): - netixlan = NetworkIXLan(**data) + def _validate_network_contact(self, data): + """ + Per github ticket #826, we only allow a Netixlan to be added + if there is a network contact that the AC can get in touch + with to resolve issues. + """ + network = data["network"] + poc = ( + network.poc_set_active.filter( + role__in=["Technical", "NOC", "Policy"], visible__in=["Users", "Public"] + ) + .exclude(email="") + .count() + ) + + if poc == 0: + raise serializers.ValidationError( + _( + "Network must have a Technical, NOC, or Policy point of contact " + "with valid email before adding exchange point." + ) + ) + + def validate(self, data): + + self._validate_network_contact(data) + + netixlan = NetworkIXLan(**data) try: netixlan.validate_ipaddr4() except ValidationError as exc: @@ -1475,6 +1507,11 @@ class NetworkIXLanSerializer(ModelSerializer): except ValidationError as exc: raise serializers.ValidationError({"ipaddr6": exc.message}) + try: + netixlan.validate_speed() + except ValidationError as exc: + raise serializers.ValidationError({"speed": exc.message}) + # when validating an existing netixlan that has a mismatching # asn value raise a validation error stating that it needs # to be moved diff --git a/peeringdb_server/signals.py b/peeringdb_server/signals.py index acfdbc63..bd7b1e1d 100644 --- a/peeringdb_server/signals.py +++ b/peeringdb_server/signals.py @@ -407,6 +407,7 @@ if getattr(settings, "DISABLE_VERIFICATION_QUEUE", False) is False: item = instance.item user = instance.user + if type(item) in QUEUE_NOTIFY and not getattr( settings, "DISABLE_VERIFICATION_QUEUE_EMAILS", False ): @@ -416,7 +417,10 @@ if getattr(settings, "DISABLE_VERIFICATION_QUEUE", False) is False: else: rdap = None - title = f"{instance.content_type} - {item}" + with override('en'): + entity_type_name = str(instance.content_type) + + title = f"{entity_type_name} - {item}" if is_suggested(item): title = f"[SUGGEST] {title}" @@ -425,7 +429,7 @@ if getattr(settings, "DISABLE_VERIFICATION_QUEUE", False) is False: title, loader.get_template("email/notify-pdb-admin-vq.txt").render( { - "entity_type_name": str(instance.content_type), + "entity_type_name": entity_type_name, "suggested": is_suggested(item), "item": item, "user": user, diff --git a/peeringdb_server/templates/site/view.html b/peeringdb_server/templates/site/view.html index 498b6e39..42b35f51 100644 --- a/peeringdb_server/templates/site/view.html +++ b/peeringdb_server/templates/site/view.html @@ -148,16 +148,30 @@ {% elif row.type == "flags" %}
{% for flag in row.value %} - - + + + + + + {{ flag.label }} + + {% if flag.help_text %} + + {% endif %} - {{ flag.label }} {% endfor %}
{% elif row.type == "action" %} diff --git a/peeringdb_server/templates/site/view_organization_tools.html b/peeringdb_server/templates/site/view_organization_tools.html index 253f14fc..6e6a747c 100644 --- a/peeringdb_server/templates/site/view_organization_tools.html +++ b/peeringdb_server/templates/site/view_organization_tools.html @@ -119,7 +119,7 @@
{% trans "Zip-Code" %}
diff --git a/peeringdb_server/templates/site/view_suggest_fac.html b/peeringdb_server/templates/site/view_suggest_fac.html index 4e2884ae..7b3442fc 100644 --- a/peeringdb_server/templates/site/view_suggest_fac.html +++ b/peeringdb_server/templates/site/view_suggest_fac.html @@ -47,6 +47,7 @@ Thank you for your suggestion.
{% trans "Website" %}
@@ -89,7 +90,7 @@ Thank you for your suggestion.
{% trans "Zip-Code" %}
diff --git a/peeringdb_server/templatetags/util.py b/peeringdb_server/templatetags/util.py index 065fd1f2..0feed9c1 100644 --- a/peeringdb_server/templatetags/util.py +++ b/peeringdb_server/templatetags/util.py @@ -8,6 +8,7 @@ from peeringdb_server.models import ( Facility, Organization, PARTNERSHIP_LEVELS, + format_speed, ) from peeringdb_server.views import DoNotRender @@ -180,13 +181,7 @@ def pretty_speed(value): if not value: return "" try: - value = int(value) - if value >= 1000000: - return "%dT" % (value / 10 ** 6) - elif value >= 1000: - return "%dG" % (value / 10 ** 3) - else: - return "%dM" % value + return format_speed(value) except ValueError: return value diff --git a/peeringdb_server/validators.py b/peeringdb_server/validators.py index 538d7e79..e2d51aa1 100644 --- a/peeringdb_server/validators.py +++ b/peeringdb_server/validators.py @@ -41,6 +41,30 @@ def validate_phonenumber(phonenumber, country=None): raise ValidationError(_("Not a valid phone number (E.164)")) +def validate_zipcode(zipcode, country): + """ + Validate a zipcode for a country. If a country has zipcodes, a zipcode + is required. If a country does not have zipcodes, it's not required. + + + Arguments: + - zipcode (can be Str or None at this point) + - country (two-letter country-code provided in data) + Raises: + - ValidationError if Zipcode is missing from a country WITH + zipcodes + Returns: + - str: zipcode + """ + if country in settings.NON_ZIPCODE_COUNTRIES: + return "" + else: + if (zipcode is None) or (zipcode == ""): + raise ValidationError(_("Input required")) + else: + return zipcode + + def validate_prefix(prefix): """ validate ip prefix diff --git a/peeringdb_server/views.py b/peeringdb_server/views.py index 550b808e..f56d4ca6 100644 --- a/peeringdb_server/views.py +++ b/peeringdb_server/views.py @@ -1596,10 +1596,7 @@ def view_network(request, id): { "name": "info_never_via_route_servers", "label": _("Never via route servers"), - # FIXME: change to `field_help` after merging with #228 - "help_text": Network._meta.get_field( - "info_never_via_route_servers" - ).help_text, + "help_text": field_help(Network, "info_never_via_route_servers"), "value": network_d.get("info_never_via_route_servers", False), }, ], diff --git a/tests/django_init.py b/tests/django_init.py index f3eb6edd..a380bcf1 100644 --- a/tests/django_init.py +++ b/tests/django_init.py @@ -170,6 +170,8 @@ settings.configure( DATA_QUALITY_MIN_PREFIXLEN_V6=64, DATA_QUALITY_MAX_PREFIXLEN_V6=116, DATA_QUALITY_MAX_IRR_DEPTH=3, + DATA_QUALITY_MAX_SPEED=1000000, + DATA_QUALITY_MIN_SPEED=100, TUTORIAL_MODE=False, CAPTCHA_TEST_MODE=True, SITE_ID=1, @@ -198,4 +200,74 @@ settings.configure( IXF_IMPORTER_DAYS_UNTIL_TICKET=6, DESKPRO_URL="test", DESKPRO_KEY="test", + NON_ZIPCODE_COUNTRIES={ + "AE": "United Arab Emirates", + "AG": "Antigua and Barbuda", + "AN": "Netherlands Antilles", + "AO": "Angola", + "AW": "Aruba", + "BF": "Burkina Faso", + "BI": "Burundi", + "BJ": "Benin", + "BS": "Bahamas", + "BW": "Botswana", + "BZ": "Belize", + "CD": "Congo, the Democratic Republic of the", + "CF": "Central African Republic", + "CG": "Congo", + "CI": "Cote d'Ivoire", + "CK": "Cook Islands", + "CM": "Cameroon", + "DJ": "Djibouti", + "DM": "Dominica", + "ER": "Eritrea", + "FJ": "Fiji", + "GD": "Grenada", + "GH": "Ghana", + "GM": "Gambia", + "GN": "Guinea", + "GQ": "Equatorial Guinea", + "GY": "Guyana", + "HK": "Hong Kong", + "IE": "Ireland", + "JM": "Jamaica", + "KE": "Kenya", + "KI": "Kiribati", + "KM": "Comoros", + "KN": "Saint Kitts and Nevis", + "KP": "North Korea", + "LC": "Saint Lucia", + "ML": "Mali", + "MO": "Macao", + "MR": "Mauritania", + "MS": "Montserrat", + "MU": "Mauritius", + "MW": "Malawi", + "NR": "Nauru", + "NU": "Niue", + "PA": "Panama", + "QA": "Qatar", + "RW": "Rwanda", + "SB": "Solomon Islands", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SO": "Somalia", + "SR": "Suriname", + "ST": "Sao Tome and Principe", + "SY": "Syria", + "TF": "French Southern Territories", + "TK": "Tokelau", + "TL": "Timor-Leste", + "TO": "Tonga", + "TT": "Trinidad and Tobago", + "TV": "Tuvalu", + "TZ": "Tanzania, United Republic of", + "UG": "Uganda", + "VU": "Vanuatu", + "YE": "Yemen", + "ZA": "South Africa", + "ZW": "Zimbabwe", + }, + VQUEUE_USER_MAX_AGE=90, + IXF_RESEND_FAILED_EMAILS=False ) diff --git a/tests/test_admin.py b/tests/test_admin.py index 39cab2e7..89570454 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -387,6 +387,43 @@ class AdminTests(TestCase): assert netixlan.ipaddr4 is None assert "Ip address already exists elsewhere" in response.content.decode("utf-8") + def test_validate_netixlan_speed(self): + ixlan = self.entities["ixlan"][0] + netixlan = ixlan.netixlan_set.first() + + url = reverse( + "admin:{}_{}_change".format( + netixlan._meta.app_label, netixlan._meta.object_name, + ).lower(), + args=(netixlan.id,), + ) + original_speed = netixlan.speed + data = { + "status": netixlan.status, + "asn": netixlan.asn, + "ipaddr4": netixlan.ipaddr4, + "ipaddr6": "", + "notes": netixlan.notes, + "speed": 1200000, + "operational": netixlan.operational, + "network": netixlan.network_id, + "ixlan": netixlan.ixlan_id, + "_save": "Save", + } + client = Client() + client.force_login(self.admin_user) + + response = client.post(url, data) + netixlan.refresh_from_db() + assert "Maximum speed: 1T" in response.content.decode("utf-8") + assert netixlan.speed == original_speed + + data["speed"] = 10 + response = client.post(url, data) + netixlan.refresh_from_db() + assert "Minimum speed: 100M" in response.content.decode("utf-8") + assert netixlan.speed == original_speed + def _run_regex_search(self, model, search_term): c = Client() c.login(username="admin", password="admin") @@ -695,19 +732,14 @@ class AdminTests(TestCase): org = models.Organization.objects.first() - url = reverse( - "admin:peeringdb_server_organization_changelist", - ) + url = reverse("admin:peeringdb_server_organization_changelist",) - response = client.post(url, { - "_selected_action": org.id, - "action": "soft_delete" - }, follow=True) + response = client.post( + url, {"_selected_action": org.id, "action": "soft_delete"}, follow=True + ) assert response.status_code == 200 messages = list(get_messages(response.wsgi_request)) assert len(messages) == 1 assert "Protected object" in str(messages[0]) - - diff --git a/tests/test_cmd_ixf_import_resets.py b/tests/test_cmd_ixf_import_resets.py deleted file mode 100644 index bf95d59e..00000000 --- a/tests/test_cmd_ixf_import_resets.py +++ /dev/null @@ -1,186 +0,0 @@ -import json -import os -from pprint import pprint -import reversion -import requests -import jsonschema -import time -import io -import datetime - -from django.core.management import call_command - -from peeringdb_server.models import ( - Organization, - Network, - NetworkIXLan, - NetworkContact, - IXLan, - IXLanPrefix, - InternetExchange, - IXFMemberData, - IXLanIXFMemberImportLog, - User, - DeskProTicket, - IXFImportEmail, -) -from peeringdb_server import ixf -import pytest - - -@pytest.mark.django_db -def test_reset_hints(entities, data_cmd_ixf_hints): - ixf_import_data = json.loads(data_cmd_ixf_hints.json) - importer = ixf.Importer() - ixlan = entities["ixlan"] - # Create IXFMemberData - importer.update(ixlan, data=ixf_import_data) - - call_command("pdb_ixf_ixp_member_import", reset_hints=True, commit=True) - - assert IXFMemberData.objects.count() == 0 - assert DeskProTicket.objects.filter(body__contains="reset_hints").count() == 1 - - -@pytest.mark.django_db -def test_reset_dismissals(entities, data_cmd_ixf_dismissals): - ixf_import_data = json.loads(data_cmd_ixf_dismissals.json) - importer = ixf.Importer() - ixlan = entities["ixlan"] - # Create IXFMemberData - importer.update(ixlan, data=ixf_import_data) - - # Dismiss all IXFMemberData - for ixfm in IXFMemberData.objects.all(): - ixfm.dismissed = True - ixfm.save() - - call_command("pdb_ixf_ixp_member_import", reset_dismisses=True, commit=True) - - assert IXFMemberData.objects.filter(dismissed=False).count() == 4 - assert DeskProTicket.objects.filter(body__contains="reset_dismisses").count() == 1 - - -@pytest.mark.django_db -def test_reset_email(entities, data_cmd_ixf_email): - ixf_import_data = json.loads(data_cmd_ixf_email.json) - importer = ixf.Importer() - ixlan = entities["ixlan"] - # Create IXFMemberData - importer.update(ixlan, data=ixf_import_data) - importer.notify_proposals() - assert IXFImportEmail.objects.count() == 1 - - call_command("pdb_ixf_ixp_member_import", reset_email=True, commit=True) - - assert IXFImportEmail.objects.count() == 0 - assert DeskProTicket.objects.filter(body__contains="reset_email").count() == 1 - - -@pytest.mark.django_db -def test_reset_tickets(deskprotickets): - assert DeskProTicket.objects.count() == 5 - - call_command("pdb_ixf_ixp_member_import", reset_tickets=True, commit=True) - - assert DeskProTicket.objects.count() == 2 - assert DeskProTicket.objects.filter(body__contains="reset_tickets").count() == 1 - - -@pytest.mark.django_db -def test_reset_all(entities, deskprotickets, data_cmd_ixf_reset): - ixf_import_data = json.loads(data_cmd_ixf_reset.json) - importer = ixf.Importer() - ixlan = entities["ixlan"] - # Create IXFMemberData - importer.update(ixlan, data=ixf_import_data) - importer.notify_proposals() - - assert DeskProTicket.objects.count() == 5 - assert IXFMemberData.objects.count() == 4 - assert IXFImportEmail.objects.count() == 1 - - call_command("pdb_ixf_ixp_member_import", reset=True, commit=True) - - assert DeskProTicket.objects.count() == 2 - assert DeskProTicket.objects.filter(body__contains="reset").count() == 1 - assert IXFMemberData.objects.count() == 0 - assert IXFImportEmail.objects.count() == 0 - - -@pytest.fixture -def entities(): - entities = {} - with reversion.create_revision(): - entities["org"] = Organization.objects.create(name="Netflix", status="ok") - entities["ix"] = InternetExchange.objects.create( - name="Test Exchange One", - org=entities["org"], - status="ok", - tech_email="ix1@localhost", - ) - entities["ixlan"] = entities["ix"].ixlan - - # create ixlan prefix(s) - entities["ixpfx"] = [ - IXLanPrefix.objects.create( - ixlan=entities["ixlan"], - status="ok", - prefix="195.69.144.0/22", - protocol="IPv4", - ), - IXLanPrefix.objects.create( - ixlan=entities["ixlan"], - status="ok", - prefix="2001:7f8:1::/64", - protocol="IPv6", - ), - ] - entities["net"] = Network.objects.create( - name="Network w allow ixp update disabled", - org=entities["org"], - asn=1001, - allow_ixp_update=False, - status="ok", - info_prefixes4=42, - info_prefixes6=42, - website="http://netflix.com/", - policy_general="Open", - policy_url="https://www.netflix.com/openconnect/", - info_unicast=True, - info_ipv6=True, - ) - - entities["netcontact"] = NetworkContact.objects.create( - email="network1@localhost", - network=entities["net"], - status="ok", - role="Policy", - ) - - admin_user = User.objects.create_user("admin", "admin@localhost", "admin") - ixf_importer_user = User.objects.create_user( - "ixf_importer", "ixf_importer@localhost", "ixf_importer" - ) - entities["org"].admin_usergroup.user_set.add(admin_user) - return entities - - -@pytest.fixture -def deskprotickets(): - """ - Creates several deskprotickets. 4 begin with [IX-F], 1 doesn't. - """ - user, _ = User.objects.get_or_create(username="ixf_importer") - message = "test" - - subjects = [ - "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.147.250 2001:7f8:1::a500:2906:1", - "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.148.250 2001:7f8:1::a500:2907:2", - "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.146.250 2001:7f8:1::a500:2908:2", - "[IX-F] Suggested:ADD Test Exchange One AS1001 195.69.149.250 2001:7f8:1::a500:2909:2", - "Unrelated Issue: Urgent!!!", - ] - for subject in subjects: - DeskProTicket.objects.create(subject=subject, body=message, user=user) - return DeskProTicket.objects.all() diff --git a/tests/test_cmd_ixf_ixp_member_import.py b/tests/test_cmd_ixf_ixp_member_import.py new file mode 100644 index 00000000..b8f3e2d4 --- /dev/null +++ b/tests/test_cmd_ixf_ixp_member_import.py @@ -0,0 +1,417 @@ +import json +import os +from pprint import pprint +import reversion +import requests +import jsonschema +import time +import io +import datetime + +from django.core.management import call_command +from django.test import override_settings + +from peeringdb_server.models import ( + Organization, + Network, + NetworkIXLan, + NetworkContact, + IXLan, + IXLanPrefix, + InternetExchange, + IXFMemberData, + IXLanIXFMemberImportLog, + User, + DeskProTicket, + IXFImportEmail, +) +from peeringdb_server import ixf +import pytest + + +@pytest.mark.django_db +def test_reset_hints(entities, data_cmd_ixf_hints): + ixf_import_data = json.loads(data_cmd_ixf_hints.json) + importer = ixf.Importer() + ixlan = entities["ixlan"] + # Create IXFMemberData + importer.update(ixlan, data=ixf_import_data) + + call_command("pdb_ixf_ixp_member_import", reset_hints=True, commit=True) + + assert IXFMemberData.objects.count() == 0 + assert DeskProTicket.objects.filter(body__contains="reset_hints").count() == 1 + + +@pytest.mark.django_db +def test_reset_dismissals(entities, data_cmd_ixf_dismissals): + ixf_import_data = json.loads(data_cmd_ixf_dismissals.json) + importer = ixf.Importer() + ixlan = entities["ixlan"] + # Create IXFMemberData + importer.update(ixlan, data=ixf_import_data) + + # Dismiss all IXFMemberData + for ixfm in IXFMemberData.objects.all(): + ixfm.dismissed = True + ixfm.save() + + call_command("pdb_ixf_ixp_member_import", reset_dismisses=True, commit=True) + + assert IXFMemberData.objects.filter(dismissed=False).count() == 4 + assert DeskProTicket.objects.filter(body__contains="reset_dismisses").count() == 1 + + +@pytest.mark.django_db +def test_reset_email(entities, data_cmd_ixf_email): + ixf_import_data = json.loads(data_cmd_ixf_email.json) + importer = ixf.Importer() + ixlan = entities["ixlan"] + # Create IXFMemberData + importer.update(ixlan, data=ixf_import_data) + importer.notify_proposals() + assert IXFImportEmail.objects.count() == 1 + + call_command("pdb_ixf_ixp_member_import", reset_email=True, commit=True) + + assert IXFImportEmail.objects.count() == 0 + assert DeskProTicket.objects.filter(body__contains="reset_email").count() == 1 + + +@pytest.mark.django_db +def test_reset_tickets(deskprotickets): + assert DeskProTicket.objects.count() == 5 + + call_command("pdb_ixf_ixp_member_import", reset_tickets=True, commit=True) + + assert DeskProTicket.objects.count() == 2 + assert DeskProTicket.objects.filter(body__contains="reset_tickets").count() == 1 + + +@pytest.mark.django_db +def test_reset_all(entities, deskprotickets, data_cmd_ixf_reset): + ixf_import_data = json.loads(data_cmd_ixf_reset.json) + importer = ixf.Importer() + ixlan = entities["ixlan"] + # Create IXFMemberData + importer.update(ixlan, data=ixf_import_data) + importer.notify_proposals() + + assert DeskProTicket.objects.count() == 5 + assert IXFMemberData.objects.count() == 4 + assert IXFImportEmail.objects.count() == 1 + + call_command("pdb_ixf_ixp_member_import", reset=True, commit=True) + + assert DeskProTicket.objects.count() == 2 + assert DeskProTicket.objects.filter(body__contains="reset").count() == 1 + assert IXFMemberData.objects.count() == 0 + assert IXFImportEmail.objects.count() == 0 + + +@pytest.mark.django_db +def test_reset_all(entities, deskprotickets, data_cmd_ixf_reset): + ixf_import_data = json.loads(data_cmd_ixf_reset.json) + importer = ixf.Importer() + ixlan = entities["ixlan"] + # Create IXFMemberData + importer.update(ixlan, data=ixf_import_data) + importer.notify_proposals() + + assert DeskProTicket.objects.count() == 5 + assert IXFMemberData.objects.count() == 4 + assert IXFImportEmail.objects.count() == 1 + + call_command("pdb_ixf_ixp_member_import", reset=True, commit=True) + + assert DeskProTicket.objects.count() == 2 + assert DeskProTicket.objects.filter(body__contains="reset").count() == 1 + assert IXFMemberData.objects.count() == 0 + assert IXFImportEmail.objects.count() == 0 + + +# Want to test that runtime errors are captured, output +# to stderr, and that the commandline tool exits with +# a status of 1 +@pytest.mark.django_db +def test_runtime_errors(entities, capsys, mocker): + ixlan = entities["ixlan"] + ixlan.ixf_ixp_import_enabled = True + ixlan.ixf_ixp_member_list_url = "http://www.localhost.com" + ixlan.save() + asn = entities["net"].asn + + """ + When importer.update() is called within the commandline + tool, we want to throw an unexpected error. + """ + mocker.patch( + "peeringdb_server.management.commands.pdb_ixf_ixp_member_import.ixf.Importer.update", + side_effect=RuntimeError("Unexpected error"), + ) + + with pytest.raises(SystemExit) as pytest_wrapped_exit: + call_command( + "pdb_ixf_ixp_member_import", + skip_import=True, + commit=True, + ixlan=[ixlan.id], + asn=asn, + ) + + # Assert we are outputting the exception and traceback to the stderr + captured = capsys.readouterr() + assert "Unexpected error" in captured.err + + # Assert we are exiting with status code 1 + assert pytest_wrapped_exit.value.code == 1 + + +# Want to test that other uncaught errors also exit with +# exit code 1 +@pytest.mark.django_db +def test_runtime_errors_uncaught(entities, capsys, mocker): + ixlan = entities["ixlan"] + ixlan.ixf_ixp_import_enabled = True + ixlan.ixf_ixp_member_list_url = "http://www.localhost.com" + ixlan.save() + asn = entities["net"].asn + + """ + When importer.notify_proposals() is called within the commandline + tool, we want to throw an unexpected error. This happens outside + the error logging. + """ + mocker.patch( + "peeringdb_server.management.commands.pdb_ixf_ixp_member_import.ixf.Importer.notify_proposals", + side_effect=ImportError("Uncaught bug"), + ) + + """ + Here we have to just assert that the commandline tool will raise that error + itself, as opposed to being wrapped in a system exit + """ + with pytest.raises(ImportError) as pytest_uncaught_error: + call_command( + "pdb_ixf_ixp_member_import", + skip_import=True, + commit=True, + ixlan=[ixlan.id], + asn=asn, + ) + + # Assert we are outputting the exception and traceback to the stderr + assert "Uncaught bug" in str(pytest_uncaught_error.value) + +# This is the normal test case for resending emails +@pytest.mark.django_db +@override_settings(MAIL_DEBUG=False, IXF_RESEND_FAILED_EMAILS=True, IXF_NOTIFY_NET_ON_CONFLICT=True, IXF_NOTIFY_IX_ON_CONFLICT=True) +def test_resend_emails(unsent_emails): + importer = ixf.Importer() + unsent_email_count = len(unsent_emails) + + assert IXFImportEmail.objects.count() == unsent_email_count + assert IXFImportEmail.objects.filter(sent__isnull=False).count() == 0 + + importer.resend_emails() + assert ( + IXFImportEmail.objects.filter(sent__isnull=False).count() == unsent_email_count + ) + + +# MAIL DEBUG must be FALSE to send emails - here they don't send +@pytest.mark.django_db +@override_settings(MAIL_DEBUG=True, IXF_RESEND_FAILED_EMAILS=True, IXF_NOTIFY_NET_ON_CONFLICT=True, IXF_NOTIFY_IX_ON_CONFLICT=True) +def test_resend_emails_mail_debug(unsent_emails): + importer = ixf.Importer() + unsent_email_count = len(unsent_emails) + + assert IXFImportEmail.objects.count() == unsent_email_count + importer.resend_emails() + assert ( + IXFImportEmail.objects.filter(sent__isnull=False).count() == 0 + ) + +# IXF_RESEND_FAILED_EMAILS must be TRUE to send emails - here they don't send +@pytest.mark.django_db +@override_settings(IXF_RESEND_FAILED_EMAILS=False, MAIL_DEBUG=False, IXF_NOTIFY_NET_ON_CONFLICT=True, IXF_NOTIFY_IX_ON_CONFLICT=True) +def test_resend_emails_resend_mode_off(unsent_emails): + importer = ixf.Importer() + unsent_email_count = len(unsent_emails) + + assert IXFImportEmail.objects.count() == unsent_email_count + importer.resend_emails() + assert ( + IXFImportEmail.objects.filter(sent__isnull=False).count() == 0 + ) + +# Here we check that the "stale info" is only ever appended once +@pytest.mark.django_db +@override_settings(MAIL_DEBUG=False, IXF_RESEND_FAILED_EMAILS=True, IXF_NOTIFY_NET_ON_CONFLICT=True, IXF_NOTIFY_IX_ON_CONFLICT=True) +def test_resend_emails_messages(unsent_emails): + importer = ixf.Importer() + unsent_email_count = len(unsent_emails) + + assert IXFImportEmail.objects.count() == unsent_email_count + assert IXFImportEmail.objects.filter(sent__isnull=False).count() == 0 + + importer.resend_emails() + assert ( + IXFImportEmail.objects.filter(sent__isnull=False).count() == unsent_email_count + ) + + # Check that "stale info" message has been appended + for email in IXFImportEmail.objects.filter(sent__isnull=False).all(): + assert email.message.startswith("This email could not be delivered initially and may contain stale information") + + # Make it appear if re-send failed + for email in IXFImportEmail.objects.filter(sent__isnull=False).all(): + email.sent = None + email.save() + + importer.resend_emails() + + # Check that "stale info" message is not re-appended + for email in IXFImportEmail.objects.filter(sent__isnull=False).all(): + assert email.message.count( + "This email could not be delivered initially and may contain stale information") == 1 + +# Now we call the email resending from the commandline +@pytest.mark.django_db +@override_settings(MAIL_DEBUG=False, IXF_RESEND_FAILED_EMAILS=True, IXF_NOTIFY_NET_ON_CONFLICT=True, IXF_NOTIFY_IX_ON_CONFLICT=True) +def test_cmd_resend_emails(unsent_emails): + unsent_email_count = len(unsent_emails) + + assert IXFImportEmail.objects.count() == unsent_email_count + assert IXFImportEmail.objects.filter(sent__isnull=False).count() == 0 + + call_command("pdb_ixf_ixp_member_import", commit=True) + + assert ( + IXFImportEmail.objects.filter(sent__isnull=False).count() == unsent_email_count + ) + +# Commit mode extends to email resending as well +@pytest.mark.django_db +@override_settings(MAIL_DEBUG=False, IXF_RESEND_FAILED_EMAILS=True, IXF_NOTIFY_NET_ON_CONFLICT=True, IXF_NOTIFY_IX_ON_CONFLICT=True) +def test_cmd_resend_emails_non_commit(unsent_emails): + unsent_email_count = len(unsent_emails) + + assert IXFImportEmail.objects.count() == unsent_email_count + call_command("pdb_ixf_ixp_member_import", commit=False) + assert ( + IXFImportEmail.objects.filter(sent__isnull=False).count() == 0 + ) + + +@pytest.fixture +def entities(): + entities = {} + with reversion.create_revision(): + entities["org"] = Organization.objects.create(name="Netflix", status="ok") + entities["ix"] = InternetExchange.objects.create( + name="Test Exchange One", + org=entities["org"], + status="ok", + tech_email="ix1@localhost", + ) + entities["ixlan"] = entities["ix"].ixlan + + # create ixlan prefix(s) + entities["ixpfx"] = [ + IXLanPrefix.objects.create( + ixlan=entities["ixlan"], + status="ok", + prefix="195.69.144.0/22", + protocol="IPv4", + ), + IXLanPrefix.objects.create( + ixlan=entities["ixlan"], + status="ok", + prefix="2001:7f8:1::/64", + protocol="IPv6", + ), + ] + entities["net"] = Network.objects.create( + name="Network w allow ixp update disabled", + org=entities["org"], + asn=1001, + allow_ixp_update=False, + status="ok", + info_prefixes4=42, + info_prefixes6=42, + website="http://netflix.com/", + policy_general="Open", + policy_url="https://www.netflix.com/openconnect/", + info_unicast=True, + info_ipv6=True, + ) + + entities["netcontact"] = NetworkContact.objects.create( + email="network1@localhost", + network=entities["net"], + status="ok", + role="Policy", + ) + + admin_user = User.objects.create_user("admin", "admin@localhost", "admin") + ixf_importer_user = User.objects.create_user( + "ixf_importer", "ixf_importer@localhost", "ixf_importer" + ) + entities["org"].admin_usergroup.user_set.add(admin_user) + return entities + + +@pytest.fixture +def deskprotickets(): + """ + Creates several deskprotickets. 4 begin with [IX-F], 1 doesn't. + """ + user, _ = User.objects.get_or_create(username="ixf_importer") + message = "test" + + subjects = [ + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.147.250 2001:7f8:1::a500:2906:1", + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.148.250 2001:7f8:1::a500:2907:2", + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.146.250 2001:7f8:1::a500:2908:2", + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.149.250 2001:7f8:1::a500:2909:2", + "Unrelated Issue: Urgent!!!", + ] + for subject in subjects: + DeskProTicket.objects.create(subject=subject, body=message, user=user) + return DeskProTicket.objects.all() + + +@pytest.fixture +def unsent_emails(entities): + """ + Creates several unsent emails. + """ + user, _ = User.objects.get_or_create(username="ixf_importer") + message = "test" + # This will actually try to send so do not put a real email here. + recipients = entities["netcontact"].email + subjects = [ + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.147.250 2001:7f8:1::a500:2906:1", + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.148.250 2001:7f8:1::a500:2907:2", + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.146.250 2001:7f8:1::a500:2908:2", + "[IX-F] Suggested:Add Test Exchange One AS1001 195.69.149.250 2001:7f8:1::a500:2909:2", + ] + + ix = entities["ix"] + net = entities["net"] + + emails = [] + for subject in subjects: + net_email = IXFImportEmail.objects.create( + subject=subject, message=message, recipients=recipients, sent=None, ix=ix + ) + + ix_email = IXFImportEmail.objects.create( + subject=subject, message=message, recipients=recipients, sent=None, net=net + ) + + emails.append(ix_email) + emails.append(net_email) + + return emails diff --git a/tests/test_cmd_pdb_cleanup_vq.py b/tests/test_cmd_pdb_cleanup_vq.py new file mode 100644 index 00000000..f67d53ea --- /dev/null +++ b/tests/test_cmd_pdb_cleanup_vq.py @@ -0,0 +1,111 @@ +from datetime import datetime, timezone, timedelta +import pytest +import random +import string + +from django.contrib.contenttypes.models import ContentType +from django.conf import settings +from django.core.management import call_command +from django.test import override_settings + +from peeringdb_server.models import User, VerificationQueueItem + +NUM_YEAR_OLD_USERS = 20 +NUM_MONTH_OLD_USERS = 15 +NUM_DAY_OLD_USERS = 10 + + +@pytest.mark.django_db +def test_cleanup_users(year_old_users, month_old_users, day_old_users): + vqi_count = VerificationQueueItem.objects.count() + users_count = User.objects.count() + call_command("pdb_cleanup_vq", "users", commit=True) + + # Assert we deleted VQI instances + assert VerificationQueueItem.objects.count() == vqi_count - NUM_YEAR_OLD_USERS + # Assert users themselves are not deleted + assert User.objects.count() == users_count + +# Try test with maximum age of vqi to be 14 days +@pytest.mark.django_db +@override_settings(VQUEUE_USER_MAX_AGE=14) +def test_cleanup_users_override_settings(year_old_users, month_old_users, day_old_users): + vqi_count = VerificationQueueItem.objects.count() + users_count = User.objects.count() + + call_command("pdb_cleanup_vq", "users", commit=True) + # Assert we deleted more VQI instances + assert VerificationQueueItem.objects.count() == vqi_count - (NUM_YEAR_OLD_USERS + NUM_MONTH_OLD_USERS) + # Assert users themselves are not deleted + assert User.objects.count() == users_count + +@pytest.mark.django_db +def test_cleanup_users_no_commit(year_old_users, month_old_users, day_old_users): + vqi_count = VerificationQueueItem.objects.count() + users_count = User.objects.count() + + call_command("pdb_cleanup_vq", "users", commit=False) + # Assert we didn't delete VQI instances + assert VerificationQueueItem.objects.count() == vqi_count + # Assert users themselves are not deleted + assert User.objects.count() == users_count + +@pytest.mark.django_db +def test_cleanup_users_no_users(year_old_users, month_old_users, day_old_users): + vqi_count = VerificationQueueItem.objects.count() + users_count = User.objects.count() + + call_command("pdb_cleanup_vq", commit=True) + # Assert we didn't delete VQI instances + assert VerificationQueueItem.objects.count() == vqi_count + # Assert users themselves are not deleted + assert User.objects.count() == users_count + +# --------- FIXTURES AND HELPERS ----------------- + +def create_users_and_vqi(users_to_generate, days_old): + """ + Input: Number users to generate [int] + Days old to make their VerificationQueueItem [int] + Output: List of tuples, [(user, verification queue item), ...] + """ + + def random_str(): + return ''.join(random.choice(string.ascii_letters) for i in range(4)) + + def admin_user(): + user, _ = User.objects.get_or_create(username="admin") + return user + + admin_user = admin_user() + + output = [] + created_date = datetime.now(timezone.utc) - timedelta(days=days_old) + + for i in range(users_to_generate): + user = User.objects.create( + username=f'User {random_str()}', + ) + vqi = VerificationQueueItem.objects.create( + content_type=ContentType.objects.get_for_model(User), + object_id=user.id, + user=admin_user, + ) + vqi.created = created_date + vqi.save() + output.append((user, vqi)) + + return output + +@pytest.fixture() +def year_old_users(): + create_users_and_vqi(NUM_YEAR_OLD_USERS, 365) + +@pytest.fixture() +def month_old_users(): + create_users_and_vqi(NUM_MONTH_OLD_USERS, 60) + +@pytest.fixture() +def day_old_users(): + create_users_and_vqi(NUM_DAY_OLD_USERS, 1) + diff --git a/tests/test_ixf_member_import_protocol.py b/tests/test_ixf_member_import_protocol.py index 3675c6b5..4af8893e 100644 --- a/tests/test_ixf_member_import_protocol.py +++ b/tests/test_ixf_member_import_protocol.py @@ -1,6 +1,7 @@ import json import os from pprint import pprint +import pytest import reversion import requests import jsonschema @@ -9,6 +10,9 @@ import io import datetime import ipaddress +from django.test import override_settings +from django.conf import settings + from peeringdb_server.models import ( Organization, Network, @@ -24,7 +28,6 @@ from peeringdb_server.models import ( IXFImportEmail, ) from peeringdb_server import ixf -import pytest @pytest.mark.django_db @@ -767,7 +770,6 @@ def test_suggest_add_local_ixf(entities, use_ip, save): ixlan.ix.refresh_from_db() assert ixlan.ix.updated == ix_updated - if (not network.ipv4_support and use_ip(4) and not use_ip(6)) or ( not network.ipv6_support and use_ip(6) and not use_ip(4) ): @@ -954,6 +956,88 @@ def test_suggest_add(entities, use_ip, save): # Test idempotent assert_idempotent(importer, ixlan, data) +@pytest.mark.django_db +def test_suggest_add_delete(entities, use_ip_alt, save): + """ + Tests suggesting a netixlan create and a deletion + at the same time while one of the ips is nulled. + + This was observed in issue #832 + """ + + data = setup_test_data("ixf.member.3") # asn1001 + network = entities["net"]["UPDATE_DISABLED"] # asn1001 + ixlan = entities["ixlan"][0] + + # remove ip from ix-f data as per use_ip_alt fixture + if not use_ip_alt(4): + del data["member_list"][0]["connection_list"][0]["vlan_list"][0]["ipv4"] + elif not use_ip_alt(6): + del data["member_list"][0]["connection_list"][0]["vlan_list"][0]["ipv6"] + + + # we don't want the extra ix-f entry for this test + del data["member_list"][0]["connection_list"][1] + + # This appears in the remote-ixf data so should not + # create a IXFMemberData instance + entities["netixlan"].append( + NetworkIXLan.objects.create( + network=network, + ixlan=ixlan, + asn=network.asn, + speed=10000, + ipaddr4=use_ip_alt(4, "195.69.147.252"), + ipaddr6=use_ip_alt(6, "2001:7f8:1::a500:2906:2"), + status="ok", + is_rs_peer=True, + operational=True, + ) + ) + + importer = ixf.Importer() + + if not save: + return assert_idempotent(importer, ixlan, data, save=False) + + importer.update(ixlan, data=data) + importer.notify_proposals() + + if (not network.ipv6_support and not use_ip_alt(4)) or \ + (not network.ipv4_support and not use_ip_alt(6)): + + #edge case: network not supporting the only provided ip + #do nothing + assert IXFMemberData.objects.all().count() == 0 + + assert_no_emails(network, ixlan.ix) + + else: + assert IXFMemberData.objects.all().count() == 2 + + email_info = [ + ("REMOVE", network.asn, use_ip_alt(4, "195.69.147.252"), use_ip_alt(6, "2001:7f8:1::a500:2906:2")), + ("CREATE", network.asn, use_ip_alt(4, "195.69.147.250"), use_ip_alt(6, "2001:7f8:1::a500:2906:1")) + ] + + assert_ix_email(ixlan.ix, email_info) + assert_network_email(network, email_info) + + assert IXFMemberData.objects.get( + ipaddr4=use_ip_alt(4,"195.69.147.252"), + ipaddr6=use_ip_alt(6,"2001:7f8:1::a500:2906:2") + ).action == "delete" + + assert IXFMemberData.objects.get( + ipaddr4=use_ip_alt(4,"195.69.147.250"), + ipaddr6=use_ip_alt(6,"2001:7f8:1::a500:2906:1") + ).action == "add" + + + # Test idempotent + assert_idempotent(importer, ixlan, data) + + @pytest.mark.django_db def test_suggest_add_no_netixlan_local_ixf(entities, use_ip, save): @@ -1230,6 +1314,7 @@ def test_single_ipaddr_matches_no_auto_update(entities, use_ip, save): # Test idempotent assert_idempotent(importer, ixlan, data) + @pytest.mark.django_db def test_816_edge_case(entities, use_ip, save): """ @@ -1266,8 +1351,12 @@ def test_816_edge_case(entities, use_ip, save): assert IXFMemberData.objects.count() == 2 assert IXFMemberData.objects.get(asn=1001).action == "add" - assert IXFImportEmail.objects.filter(net__asn=1001, message__contains='CREATE').exists() - assert not IXFImportEmail.objects.filter(net__asn=1001, message__contains='MODIFY').exists() + assert IXFImportEmail.objects.filter( + net__asn=1001, message__contains="CREATE" + ).exists() + assert not IXFImportEmail.objects.filter( + net__asn=1001, message__contains="MODIFY" + ).exists() # Test idempotent assert_idempotent(importer, ixlan, data) @@ -2265,6 +2354,38 @@ def test_vlan_sanitize(data_ixf_vlan): assert sanitized == data_ixf_vlan.expected["vlan_list"] +@override_settings(MAIL_DEBUG=False) +@pytest.mark.django_db +def test_send_email(entities, use_ip): + # Setup is from test_suggest_add() + print(f"Debug mode for mail: {settings.MAIL_DEBUG}") + data = setup_test_data("ixf.member.3") # asn1001 + network = entities["net"]["UPDATE_DISABLED"] # asn1001 + ixlan = entities["ixlan"][0] + + # This appears in the remote-ixf data so should not + # create a IXFMemberData instance + entities["netixlan"].append( + NetworkIXLan.objects.create( + network=network, + ixlan=ixlan, + asn=network.asn, + speed=10000, + ipaddr4=use_ip(4, "195.69.147.251"), + ipaddr6=use_ip(6, "2001:7f8:1::a500:2906:3"), + status="ok", + is_rs_peer=True, + operational=True, + ) + ) + + importer = ixf.Importer() + importer.update(ixlan, data=data) + + # This should actually send an email + importer.notify_proposals() + assert importer.emails == 2 + # FIXTURES @pytest.fixture(params=[True, False]) @@ -2492,6 +2613,20 @@ def use_ip(request): return UseIPAddrWrapper(use_ipv4, use_ipv6) +@pytest.fixture(params=[(True, False), (False, True)]) +def use_ip_alt(request): + """ + Fixture that gives back 2 instances of UseIpAddrWrapper + + 1) use ip4, dont use ip6 + 2) dont use ip4, use ip6 + """ + + use_ipv4, use_ipv6 = request.param + + return UseIPAddrWrapper(use_ipv4, use_ipv6) + + # TEST FUNCTIONS def setup_test_data(filename): json_data = {} diff --git a/tests/test_ixf_member_views.py b/tests/test_ixf_member_views.py index ba74a98f..5ef3242a 100644 --- a/tests/test_ixf_member_views.py +++ b/tests/test_ixf_member_views.py @@ -104,6 +104,7 @@ def test_dismiss_ixf_proposals_no_perm(regular_user, entities, ip_addresses): assert response.status_code == 401 assert "Permission denied" in content + @pytest.mark.django_db def test_ix_order(admin_user, entities, ip_addresses, ip_addresses_other): @@ -128,7 +129,8 @@ def test_ix_order(admin_user, entities, ip_addresses, ip_addresses_other): assert response.status_code == 200 matches = re.findall('([^<]+)', content) - assert matches == ['Test Exchange One', 'Test Exchange Two'] + assert matches == ["Test Exchange One", "Test Exchange Two"] + @pytest.mark.django_db def test_dismissed_note(admin_user, entities, ip_addresses): @@ -157,13 +159,13 @@ def test_dismissed_note(admin_user, entities, ip_addresses): # create netixlan, causing the suggestion to become noop NetworkIXLan.objects.create( - network = network, - asn = network.asn, - ixlan = ixlan_a, - status = "ok", - speed = 0, - ipaddr4 = ip_addresses[0][0], - ipaddr6 = ip_addresses[0][1], + network=network, + asn=network.asn, + ixlan=ixlan_a, + status="ok", + speed=0, + ipaddr4=ip_addresses[0][0], + ipaddr6=ip_addresses[0][1], ) response = client.get(url) @@ -175,8 +177,6 @@ def test_dismissed_note(admin_user, entities, ip_addresses): assert "You have dismissed some suggestions" not in content - - @pytest.mark.django_db def test_check_ixf_proposals(admin_user, entities, ip_addresses): network = Network.objects.create( @@ -191,7 +191,7 @@ def test_check_ixf_proposals(admin_user, entities, ip_addresses): policy_general="Open", policy_url="https://www.netflix.com/openconnect/", info_unicast=False, - info_ipv6=False + info_ipv6=False, ) ixlan = entities["ixlan"][0] @@ -210,16 +210,13 @@ def test_check_ixf_proposals(admin_user, entities, ip_addresses): ) with open( - os.path.join( - os.path.dirname(__file__), "data", "ixf", "views", "import.json", - ), + os.path.join(os.path.dirname(__file__), "data", "ixf", "views", "import.json",), ) as fh: json_data = json.load(fh) importer = ixf.Importer() importer.update(ixlan, data=json_data) - client = setup_client(admin_user) url = reverse("net-view", args=(network.id,)) response = client.get(url) @@ -249,7 +246,9 @@ def create_IXFMemberData(network, ixlan, ip_addresses, dismissed): Creates IXFMember data """ for ip_address in ip_addresses: - ixfmember = IXFMemberData.instantiate(network.asn, ip_address[0], ip_address[1], ixlan, data={"foo":"bar"}) + ixfmember = IXFMemberData.instantiate( + network.asn, ip_address[0], ip_address[1], ixlan, data={"foo": "bar"} + ) ixfmember.save() ixfmember.dismissed = dismissed ixfmember.save() @@ -268,6 +267,7 @@ def ip_addresses(): ("195.69.144.5", "2001:7f8:1::a500:2906:5"), ] + @pytest.fixture def ip_addresses_other(): """ @@ -282,7 +282,6 @@ def ip_addresses_other(): ] - @pytest.fixture def entities(): entities = {} @@ -296,7 +295,7 @@ def entities(): ), InternetExchange.objects.create( name="Test Exchange Two", org=entities["org"][0], status="ok" - ) + ), ] # create ixlan(s)