diff --git a/.circleci/config.yml b/.circleci/config.yml index d883076..481795e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,12 @@ jobs: # specify the version you desire here # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` - image: circleci/python:3.7.2 - + environment: + DATABASE_URL: postgresql://circleci@127.0.0.1:5432/circle_test + - image: circleci/postgres:11.2 + environment: + POSTGRES_USER: circleci + POSTGRES_DB: circle_test # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ @@ -31,7 +36,7 @@ jobs: name: install dependencies command: | sudo pip install pipenv - pipenv install + pipenv install --dev - save_cache: paths: @@ -43,15 +48,18 @@ jobs: name: run pre-commit command: | pipenv run pre-commit run --all-files - + + - run: + name: run db migrations + command: | + pipenv run alembic upgrade head + - run: name: start gunicorn command: | pipenv run gunicorn 'service.microservice:start_service()' background: true - - # run tests! # this example uses Django's built-in test-runner # other common Python testing frameworks include pytest and nose diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26d71f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +export ACCESS_KEY=123456 +export SENTRY_DSN= + +export DATABASE_URL=postgresql://localhost/email_microservice +export SENDGRID_API_KEY=abc123 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8961e6..392f240 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +repos: - repo: local hooks: - id: pylint diff --git a/Pipfile b/Pipfile index b3867b3..da1d63e 100644 --- a/Pipfile +++ b/Pipfile @@ -18,6 +18,11 @@ pre-commit = "*" pylint = "*" sendgrid = "*" urllib3 = ">=1.26.5" +jinja2 = "*" +sqlalchemy = "*" +psycopg2 = "*" +alembic = "*" +bs4 = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index ef8374a..e3ba972 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d6d6ad8a958e8e30630c3328a6ebd0216f2c6c5242c7fe80a1f3f03efbf0e075" + "sha256": "364bc22915cddf9e5f2aa7ef59df5988a0779709c2dc21ccd0afccccb280cafb" }, "pipfile-spec": 6, "requires": { @@ -16,13 +16,21 @@ ] }, "default": { + "alembic": { + "hashes": [ + "sha256:bc5bdf03d1b9814ee4d72adc0b19df2123f6c50a60c1ea761733f3640feedb8d", + "sha256:d0c580041f9f6487d5444df672a83da9be57398f39d6c1802bbedec6fefbeef6" + ], + "index": "pypi", + "version": "==1.7.3" + }, "astroid": { "hashes": [ - "sha256:b6c2d75cd7c2982d09e7d41d70213e863b3ba34d3bd4014e08f167cee966e99e", - "sha256:ecc50f9b3803ebf8ea19aa2c6df5622d8a5c31456a53c741d3be044d96ff0948" + "sha256:dcc06f6165f415220013801642bd6c9808a02967070919c4b746c6864c205471", + "sha256:fe81f80c0b35264acb5653302ffbd935d394f1775c5e4487df745bf9c2442708" ], "markers": "python_version ~= '3.6'", - "version": "==2.7.2" + "version": "==2.8.0" }, "attrs": { "hashes": [ @@ -40,6 +48,21 @@ "markers": "python_version >= '2.7'", "version": "==1.1.0" }, + "beautifulsoup4": { + "hashes": [ + "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf", + "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891" + ], + "markers": "python_version >= '3.1'", + "version": "==4.10.0" + }, + "bs4": { + "hashes": [ + "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a" + ], + "index": "pypi", + "version": "==0.0.1" + }, "certifi": { "hashes": [ "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", @@ -49,19 +72,19 @@ }, "cfgv": { "hashes": [ - "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1", - "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1" + "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426", + "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736" ], "markers": "python_full_version >= '3.6.1'", - "version": "==3.3.0" + "version": "==3.3.1" }, "charset-normalizer": { "hashes": [ - "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b", - "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3" + "sha256:7098e7e862f6370a2a8d1a6398cd359815c45d12626267652c3f13dec58e2367", + "sha256:fa471a601dfea0f492e4f4fca035cd82155e65dc45c9b83bf4322dfab63755dd" ], "markers": "python_version >= '3'", - "version": "==2.0.4" + "version": "==2.0.5" }, "coverage": { "hashes": [ @@ -182,6 +205,62 @@ ], "version": "==3.0.12" }, + "greenlet": { + "hashes": [ + "sha256:04e1849c88aa56584d4a0a6e36af5ec7cc37993fdc1fda72b56aa1394a92ded3", + "sha256:05e72db813c28906cdc59bd0da7c325d9b82aa0b0543014059c34c8c4ad20e16", + "sha256:07e6d88242e09b399682b39f8dfa1e7e6eca66b305de1ff74ed9eb1a7d8e539c", + "sha256:090126004c8ab9cd0787e2acf63d79e80ab41a18f57d6448225bbfcba475034f", + "sha256:1796f2c283faab2b71c67e9b9aefb3f201fdfbee5cb55001f5ffce9125f63a45", + "sha256:2f89d74b4f423e756a018832cd7a0a571e0a31b9ca59323b77ce5f15a437629b", + "sha256:34e6675167a238bede724ee60fe0550709e95adaff6a36bcc97006c365290384", + "sha256:3e594015a2349ec6dcceda9aca29da8dc89e85b56825b7d1f138a3f6bb79dd4c", + "sha256:3f8fc59bc5d64fa41f58b0029794f474223693fd00016b29f4e176b3ee2cfd9f", + "sha256:3fc6a447735749d651d8919da49aab03c434a300e9f0af1c886d560405840fd1", + "sha256:40abb7fec4f6294225d2b5464bb6d9552050ded14a7516588d6f010e7e366dcc", + "sha256:44556302c0ab376e37939fd0058e1f0db2e769580d340fb03b01678d1ff25f68", + "sha256:476ba9435afaead4382fbab8f1882f75e3fb2285c35c9285abb3dd30237f9142", + "sha256:4870b018ca685ff573edd56b93f00a122f279640732bb52ce3a62b73ee5c4a92", + "sha256:4adaf53ace289ced90797d92d767d37e7cdc29f13bd3830c3f0a561277a4ae83", + "sha256:4eae94de9924bbb4d24960185363e614b1b62ff797c23dc3c8a7c75bbb8d187e", + "sha256:5317701c7ce167205c0569c10abc4bd01c7f4cf93f642c39f2ce975fa9b78a3c", + "sha256:5c3b735ccf8fc8048664ee415f8af5a3a018cc92010a0d7195395059b4b39b7d", + "sha256:5cde7ee190196cbdc078511f4df0be367af85636b84d8be32230f4871b960687", + "sha256:655ab836324a473d4cd8cf231a2d6f283ed71ed77037679da554e38e606a7117", + "sha256:6ce9d0784c3c79f3e5c5c9c9517bbb6c7e8aa12372a5ea95197b8a99402aa0e6", + "sha256:6e0696525500bc8aa12eae654095d2260db4dc95d5c35af2b486eae1bf914ccd", + "sha256:75ff270fd05125dce3303e9216ccddc541a9e072d4fc764a9276d44dee87242b", + "sha256:8039f5fe8030c43cd1732d9a234fdcbf4916fcc32e21745ca62e75023e4d4649", + "sha256:84488516639c3c5e5c0e52f311fff94ebc45b56788c2a3bfe9cf8e75670f4de3", + "sha256:84782c80a433d87530ae3f4b9ed58d4a57317d9918dfcc6a59115fa2d8731f2c", + "sha256:8ddb38fb6ad96c2ef7468ff73ba5c6876b63b664eebb2c919c224261ae5e8378", + "sha256:98b491976ed656be9445b79bc57ed21decf08a01aaaf5fdabf07c98c108111f6", + "sha256:990e0f5e64bcbc6bdbd03774ecb72496224d13b664aa03afd1f9b171a3269272", + "sha256:9b02e6039eafd75e029d8c58b7b1f3e450ca563ef1fe21c7e3e40b9936c8d03e", + "sha256:a11b6199a0b9dc868990456a2667167d0ba096c5224f6258e452bfbe5a9742c5", + "sha256:a414f8e14aa7bacfe1578f17c11d977e637d25383b6210587c29210af995ef04", + "sha256:a91ee268f059583176c2c8b012a9fce7e49ca6b333a12bbc2dd01fc1a9783885", + "sha256:ac991947ca6533ada4ce7095f0e28fe25d5b2f3266ad5b983ed4201e61596acf", + "sha256:b050dbb96216db273b56f0e5960959c2b4cb679fe1e58a0c3906fa0a60c00662", + "sha256:b97a807437b81f90f85022a9dcfd527deea38368a3979ccb49d93c9198b2c722", + "sha256:bad269e442f1b7ffa3fa8820b3c3aa66f02a9f9455b5ba2db5a6f9eea96f56de", + "sha256:bf3725d79b1ceb19e83fb1aed44095518c0fcff88fba06a76c0891cfd1f36837", + "sha256:c0f22774cd8294078bdf7392ac73cf00bfa1e5e0ed644bd064fdabc5f2a2f481", + "sha256:c1862f9f1031b1dee3ff00f1027fcd098ffc82120f43041fe67804b464bbd8a7", + "sha256:c8d4ed48eed7414ccb2aaaecbc733ed2a84c299714eae3f0f48db085342d5629", + "sha256:cf31e894dabb077a35bbe6963285d4515a387ff657bd25b0530c7168e48f167f", + "sha256:d15cb6f8706678dc47fb4e4f8b339937b04eda48a0af1cca95f180db552e7663", + "sha256:dfcb5a4056e161307d103bc013478892cfd919f1262c2bb8703220adcb986362", + "sha256:e02780da03f84a671bb4205c5968c120f18df081236d7b5462b380fd4f0b497b", + "sha256:e2002a59453858c7f3404690ae80f10c924a39f45f6095f18a985a1234c37334", + "sha256:e22a82d2b416d9227a500c6860cf13e74060cf10e7daf6695cbf4e6a94e0eee4", + "sha256:e41f72f225192d5d4df81dad2974a8943b0f2d664a2a5cfccdf5a01506f5523c", + "sha256:f253dad38605486a4590f9368ecbace95865fea0f2b66615d121ac91fd1a1563", + "sha256:fddfb31aa2ac550b938d952bca8a87f1db0f8dc930ffa14ce05b5c08d27e7fd1" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.1.1" + }, "gunicorn": { "hashes": [ "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", @@ -192,11 +271,11 @@ }, "identify": { "hashes": [ - "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c", - "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79" + "sha256:113a76a6ba614d2a3dd408b3504446bcfac0370da5995aa6a17fd7c6dffde02d", + "sha256:32f465f3c48083f345ad29a9df8419a4ce0674bf4a8c3245191d65c83634bdbf" ], "markers": "python_full_version >= '3.6.1'", - "version": "==2.2.13" + "version": "==2.2.14" }, "idna": { "hashes": [ @@ -208,11 +287,19 @@ }, "importlib-metadata": { "hashes": [ - "sha256:7b30a78db2922d78a6f47fb30683156a14f3c6aa5cc23f77cc8967e9ab2d002f", - "sha256:ed5157fef23a4bc4594615a0dd8eba94b2bb36bf2a343fa3d8bb2fa0a62a99d5" + "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15", + "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1" ], "markers": "python_version < '3.8'", - "version": "==4.6.4" + "version": "==4.8.1" + }, + "importlib-resources": { + "hashes": [ + "sha256:2480d8e07d1890056cb53c96e3de44fead9c62f2ba949b0f2e4c4345f4afa977", + "sha256:a65882a4d0fe5fbf702273456ba2ce74fe44892c25e42e057aca526b702a6d4b" + ], + "markers": "python_version < '3.9'", + "version": "==5.2.2" }, "iniconfig": { "hashes": [ @@ -229,6 +316,14 @@ "markers": "python_version < '4' and python_full_version >= '3.6.1'", "version": "==5.9.3" }, + "jinja2": { + "hashes": [ + "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", + "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" + ], + "index": "pypi", + "version": "==3.0.1" + }, "lazy-object-proxy": { "hashes": [ "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", @@ -257,6 +352,74 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.6.0" }, + "mako": { + "hashes": [ + "sha256:169fa52af22a91900d852e937400e79f535496191c63712e3b9fda5a9bed6fc3", + "sha256:6804ee66a7f6a6416910463b00d76a7b25194cd27f1918500c5bd7be2a088a23" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.1.5" + }, + "markupsafe": { + "hashes": [ + "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", + "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", + "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", + "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", + "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", + "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", + "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", + "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", + "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", + "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", + "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", + "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", + "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", + "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", + "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", + "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", + "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", + "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", + "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", + "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", + "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", + "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", + "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", + "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", + "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", + "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", + "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", + "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", + "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", + "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", + "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", + "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", + "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", + "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", + "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", + "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", + "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", + "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", + "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", + "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", + "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", + "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", + "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", + "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", + "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", + "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", + "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", + "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", + "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", + "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", + "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", + "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", + "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", + "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" + ], + "markers": "python_version >= '3.6'", + "version": "==2.0.1" + }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -281,27 +444,42 @@ }, "platformdirs": { "hashes": [ - "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c", - "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e" + "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f", + "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648" ], "markers": "python_version >= '3.6'", - "version": "==2.2.0" + "version": "==2.3.0" }, "pluggy": { "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" + "markers": "python_version >= '3.6'", + "version": "==1.0.0" }, "pre-commit": { "hashes": [ - "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c", - "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4" + "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7", + "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6" + ], + "index": "pypi", + "version": "==2.15.0" + }, + "psycopg2": { + "hashes": [ + "sha256:079d97fc22de90da1d370c90583659a9f9a6ee4007355f5825e5f1c70dffc1fa", + "sha256:2087013c159a73e09713294a44d0c8008204d06326006b7f652bef5ace66eebb", + "sha256:2c992196719fadda59f72d44603ee1a2fdcc67de097eea38d41c7ad9ad246e62", + "sha256:7640e1e4d72444ef012e275e7b53204d7fab341fb22bc76057ede22fe6860b25", + "sha256:7f91312f065df517187134cce8e395ab37f5b601a42446bdc0f0d51773621854", + "sha256:830c8e8dddab6b6716a4bf73a09910c7954a92f40cf1d1e702fb93c8a919cc56", + "sha256:89409d369f4882c47f7ea20c42c5046879ce22c1e4ea20ef3b00a4dfc0a7f188", + "sha256:bf35a25f1aaa8a3781195595577fcbb59934856ee46b4f252f56ad12b8043bcf", + "sha256:de5303a6f1d0a7a34b9d40e4d3bef684ccc44a49bbe3eb85e3c0bffb4a131b7c" ], "index": "pypi", - "version": "==2.14.0" + "version": "==2.9.1" }, "py": { "hashes": [ @@ -320,11 +498,11 @@ }, "pylint": { "hashes": [ - "sha256:6758cce3ddbab60c52b57dcc07f0c5d779e5daf0cf50f6faacbef1d3ea62d2a1", - "sha256:e178e96b6ba171f8ef51fbce9ca30931e6acbea4a155074d80cc081596c9e852" + "sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126", + "sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436" ], "index": "pypi", - "version": "==2.10.2" + "version": "==2.11.1" }, "pyparsing": { "hashes": [ @@ -336,11 +514,11 @@ }, "pytest": { "hashes": [ - "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", - "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" + "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89", + "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134" ], "index": "pypi", - "version": "==6.2.4" + "version": "==6.2.5" }, "pytest-cov": { "hashes": [ @@ -402,11 +580,11 @@ }, "sendgrid": { "hashes": [ - "sha256:0c500d53b2e7a4734bd978ebafcb43bc8be1b0cace5690a2324d6fab1806926a", - "sha256:a991ec89e619fce9f89fa28d0e13d1673f336ff1e6333a4df591242f3134fe63" + "sha256:4ae65a2657e7b2ff01a3c67c4fcfe6ecd579783870ab09d39291ed133a69299c", + "sha256:75fa5094afb216bf11c60e9147c604889fa4012a3fc6ab7715fdc12e03ac488d" ], "index": "pypi", - "version": "==6.8.0" + "version": "==6.8.1" }, "sentry-sdk": { "hashes": [ @@ -424,6 +602,50 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "soupsieve": { + "hashes": [ + "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc", + "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b" + ], + "markers": "python_version >= '3.6'", + "version": "==2.2.1" + }, + "sqlalchemy": { + "hashes": [ + "sha256:059c5f41e8630f51741a234e6ba2a034228c11b3b54a15478e61d8b55fa8bd9d", + "sha256:07b9099a95dd2b2620498544300eda590741ac54915c6b20809b6de7e3c58090", + "sha256:0aa312f9906ecebe133d7f44168c3cae4c76f27a25192fa7682f3fad505543c9", + "sha256:0aa746d1173587743960ff17b89b540e313aacfe6c1e9c81aa48393182c36d4f", + "sha256:1c15191f2430a30082f540ec6f331214746fc974cfdf136d7a1471d1c61d68ff", + "sha256:25e9b2e5ca088879ce3740d9ccd4d58cb9061d49566a0b5e12166f403d6f4da0", + "sha256:2bca9a6e30ee425cc321d988a152a5fe1be519648e7541ac45c36cd4f569421f", + "sha256:355024cf061ed04271900414eb4a22671520241d2216ddb691bdd8a992172389", + "sha256:370f4688ce47f0dc1e677a020a4d46252a31a2818fd67f5c256417faefc938af", + "sha256:37f2bd1b8e32c5999280f846701712347fc0ee7370e016ede2283c71712e127a", + "sha256:3a0d3b3d51c83a66f5b72c57e1aad061406e4c390bd42cf1fda94effe82fac81", + "sha256:43fc207be06e50158e4dae4cc4f27ce80afbdbfa7c490b3b22feb64f6d9775a0", + "sha256:448612570aa1437a5d1b94ada161805778fe80aba5b9a08a403e8ae4e071ded6", + "sha256:4803a481d4c14ce6ad53dc35458c57821863e9a079695c27603d38355e61fb7f", + "sha256:512f52a8872e8d63d898e4e158eda17e2ee40b8d2496b3b409422e71016db0bd", + "sha256:6a8dbf3d46e889d864a57ee880c4ad3a928db5aa95e3d359cbe0da2f122e50c4", + "sha256:76ff246881f528089bf19385131b966197bb494653990396d2ce138e2a447583", + "sha256:82c03325111eab88d64e0ff48b6fe15c75d23787429fa1d84c0995872e702787", + "sha256:967307ea52985985224a79342527c36ec2d1daa257a39748dd90e001a4be4d90", + "sha256:9b128a78581faea7a5ee626ad4471353eee051e4e94616dfeff4742b6e5ba262", + "sha256:a8395c4db3e1450eef2b68069abf500cc48af4b442a0d98b5d3c9535fe40cde8", + "sha256:ae07895b55c7d58a7dd47438f437ac219c0f09d24c2e7d69fdebc1ea75350f00", + "sha256:bd41f8063a9cd11b76d6d7d6af8139ab3c087f5dbbe5a50c02cb8ece7da34d67", + "sha256:be185b3daf651c6c0639987a916bf41e97b60e68f860f27c9cb6574385f5cbb4", + "sha256:cd0e85dd2067159848c7672acd517f0c38b7b98867a347411ea01b432003f8d9", + "sha256:cd68c5f9d13ffc8f4d6802cceee786678c5b1c668c97bc07b9f4a60883f36cd1", + "sha256:cec1a4c6ddf5f82191301a25504f0e675eccd86635f0d5e4c69e0661691931c5", + "sha256:d9667260125688c71ccf9af321c37e9fb71c2693575af8210f763bfbbee847c7", + "sha256:e0ce4a2e48fe0a9ea3a5160411a4c5135da5255ed9ac9c15f15f2bcf58c34194", + "sha256:e9d4f4552aa5e0d1417fc64a2ce1cdf56a30bab346ba6b0dd5e838eb56db4d29" + ], + "index": "pypi", + "version": "==1.4.23" + }, "starkbank-ecdsa": { "hashes": [ "sha256:f7b434b4a1e0ba082fb1804b908b79523973fd17b1fde377078857f7cee299d1" @@ -476,12 +698,12 @@ }, "typing-extensions": { "hashes": [ - "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", - "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", - "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" + "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", + "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", + "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" ], - "markers": "python_version < '3.8'", - "version": "==3.10.0.0" + "markers": "python_version < '3.10'", + "version": "==3.10.0.2" }, "urllib3": { "hashes": [ @@ -493,11 +715,11 @@ }, "virtualenv": { "hashes": [ - "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0", - "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06" + "sha256:4da4ac43888e97de9cf4fdd870f48ed864bbfd133d2c46cbdec941fed4a25aef", + "sha256:a4b987ec31c3c9996cf1bc865332f967fe4a0512c41b39652d6224f696e69da5" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==20.7.2" + "version": "==20.8.0" }, "wrapt": { "hashes": [ @@ -510,18 +732,18 @@ "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" ], - "markers": "python_version >= '3.6'", + "markers": "python_version < '3.10'", "version": "==3.5.0" } }, "develop": { "astroid": { "hashes": [ - "sha256:b6c2d75cd7c2982d09e7d41d70213e863b3ba34d3bd4014e08f167cee966e99e", - "sha256:ecc50f9b3803ebf8ea19aa2c6df5622d8a5c31456a53c741d3be044d96ff0948" + "sha256:dcc06f6165f415220013801642bd6c9808a02967070919c4b746c6864c205471", + "sha256:fe81f80c0b35264acb5653302ffbd935d394f1775c5e4487df745bf9c2442708" ], "markers": "python_version ~= '3.6'", - "version": "==2.7.2" + "version": "==2.8.0" }, "isort": { "hashes": [ @@ -568,19 +790,19 @@ }, "platformdirs": { "hashes": [ - "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c", - "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e" + "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f", + "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648" ], "markers": "python_version >= '3.6'", - "version": "==2.2.0" + "version": "==2.3.0" }, "pylint": { "hashes": [ - "sha256:6758cce3ddbab60c52b57dcc07f0c5d779e5daf0cf50f6faacbef1d3ea62d2a1", - "sha256:e178e96b6ba171f8ef51fbce9ca30931e6acbea4a155074d80cc081596c9e852" + "sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126", + "sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436" ], "index": "pypi", - "version": "==2.10.2" + "version": "==2.11.1" }, "toml": { "hashes": [ @@ -628,12 +850,12 @@ }, "typing-extensions": { "hashes": [ - "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", - "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", - "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" + "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", + "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", + "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" ], - "markers": "python_version < '3.8'", - "version": "==3.10.0.0" + "markers": "python_version < '3.10'", + "version": "==3.10.0.2" }, "wrapt": { "hashes": [ diff --git a/Procfile b/Procfile index 43a049c..ecc04c5 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,2 @@ -web: gunicorn 'service.microservice:start_service()' --log-file - +web: bin/qgtunnel pipenv run gunicorn 'service.microservice:start_service()' +release: bin/qgtunnel pipenv run alembic upgrade head \ No newline at end of file diff --git a/README.md b/README.md index 8926c83..c9dfea2 100755 --- a/README.md +++ b/README.md @@ -62,44 +62,27 @@ For detail of the fields in the json data, please see below.   |enable | Indicates if this setting is enabled.| optional |   |post_to_url | An Inbound Parse URL that you would like a copy of your email along with the spam report to be sent to.| optional |   | threshold | The threshold used to determine if your content qualifies as spam on a scale from 1 to 10, with 10 being most strict, or most likely to be considered as spam.| optional | -|personalizations | An array of messages and their metadata. Each object within personalizations can be thought of as an envelope - it defines who should receive an individual message and how that message should be handled.| ||required | - |to| An array of recipients. Each object within this array may contain the name, but must always contain the email, of a recipient.|| required| -  |email | Email | required | -  |name | The name of the person or company that is sending the email.| optional| - |bcc | An array of recipients who will receive a blind carbon copy of your email. Each object within this array may contain the name, but must always contain the email, of a recipient.| |optional | -  |email | Email | required | -  |name | The name of the person or company that is sending the email. | optional| - |cc | An array of recipients who will receive a copy of your email. Each object within this array may contain the name, but must always contain the email, of a recipient.|| optional | -  |email | Email | required | -  |name | The name of the person or company that is sending the email. | optional| - |custom_args| Values that are specific to this personalization that will be carried along with the email and its activity data. Substitutions will not be made on custom arguments, so any string that is entered into this parameter will be assumed to be the custom argument that you would like to be used. May not exceed 10,000 bytes.| | optional | - |headers| A collection of JSON key/value pairs allowing you to specify specific handling instructions for your email. You may not overwrite the following headers: x-sg-id, x-sg-eid, received, dkim-signature, Content-Type, Content-Transfer-Encoding, To, From, Subject, Reply-To, CC, BCC || optional| - |subject |The subject of your email. Char length requirements, according to the RFC - http://stackoverflow.com/questions/1592291/what-is-the-email-subject-length-limit#answer-1592310 | | required | |reply_to | | required |  |email | Email | | required |  |name | The name of the person or company that is sending the email. || optional| |sections| An object of key/value pairs that define block sections of code to be used as substitutions. The key/value pairs must be strings. ||| optional| -|send_at | A unix timestamp allowing you to specify when you want your email to be delivered. This may be overridden by the personalizations[x].send_at parameter. You can't schedule more than 72 hours in advance. If you have the flexibility, it's better to schedule mail for off-peak times. Most emails are scheduled and sent at the top of the hour or half hour. Scheduling email to avoid those times (for example, scheduling at 10:53) can result in lower deferral rates because it won't be going through our servers at the same times as everyone else's mail.||| optional | |subject| The subject of your email. Char length requirements, according to the RFC - http://stackoverflow.com/questions/1592291/what-is-the-email-subject-length-limit#answer-1592310| ||required| -|template_id | The id of a template that you would like to use. If you use a template that contains a subject and content (either text or html), you do not need to specify those at the personalizations nor message level.| ||optional | -|dynamic_template_data| If `template_id` is specified, the key/value pair will be mapped in the template. See [Dynamic Template Data](https://sendgrid.com/docs/ui/sending-email/how-to-send-an-email-with-dynamic-transactional-templates/) for more details.| || optional | -|tracking_settings | Settings to determine how you would like to track the metrics of how your recipients interact with your email.| || optional | - |click_tracking| Allows you to track whether a recipient clicked a link in your email.|| optional| -  |enable| Indicates if this setting is enabled.| optional| -  |enable_text|Indicates if this setting should be included in the text/plain portion of your email. | optional| - |ganalytics |Allows you to enable tracking provided by Google Analytics. | | optional| -  |enable | Indicates if this setting is enabled.| optional| -  |utm_source Name of the referrer source. (e.g. Google SomeDomain.com or Marketing Email) | optional| -  |utm_medium NAME OF YOUR MARKETING MEDIUM e.g. email| optional| -  |utm_term IDENTIFY PAID KEYWORDS HERE| optional| -  |utm_content USE THIS SPACE TO DIFFERENTIATE YOUR EMAIL FROM ADS| optional| -  |utm_campaign The name of the campaign| optional| - |open_tracking |Allows you to track whether the email was opened or not, but including a single pixel image in the body of the content. When the pixel is loaded, we can log that the email was opened. | | optional | -  |enable | Indicates if this setting is enabled.| optional| -  |substitution_tag| Allows you to specify a substitution tag that you can insert in the body of your email at a location that you desire. This tag will be replaced by the open tracking pixel. | optional| - |subscription_tracking |Allows you to insert a subscription management link at the bottom of the text and html bodies of your email. If you would like to specify the location of the link within your email, you may use the substitution_tag. | | optional| -  |enable | Indicates if this setting is enabled.| optional| -  |html | HTML to be appended to the email, with the subscription tracking link. You may control where the link is by using the tag <% %>| optional| If you would like to unsubscribe and stop receiving these emails <% clickhere %>. -  |substitution_tag| A tag that will be replaced with the unsubscribe URL. for example: [unsubscribe_url]. If this parameter is used, it will override both the text and html parameters. The URL of the link will be placed at the substitution tag’s location, with no additional formatting. | optional| -  |text | Text to be appended to the email, with the subscription tracking link. You may control where the link is by using the tag <% %>| optional| +## Testing +Code coverage command with missing statement line numbers +``` +pipenv run python -m pytest -s --cov=service --cov=tasks tests/ --cov-report term-missing +``` + +## Revising the database +Create a migration +``` +pipenv run alembic revision -m "Add a column" +``` +Edit the created revision file to add the steps to implement and rollback +the changes you want to make. + +Run DB migrations +``` +pipenv run alembic upgrade head +``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..2d4431e --- /dev/null +++ b/alembic.ini @@ -0,0 +1,100 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. Valid values are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # default: use os.pathsep + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app.json b/app.json new file mode 100644 index 0000000..ebf4d43 --- /dev/null +++ b/app.json @@ -0,0 +1,27 @@ +{ + "addons": [ + { + "plan": "papertrail:choklad" + }, + { + "plan": "quotaguardstatic:starter" + } + ], + "buildpacks": [ + { + "url": "heroku/python" + }, + { + "url": "https://github.com/SFDigitalServices/heroku-configvar-files-buildpack" + } + ], + "env": { + }, + "formation": { + }, + "name": "email-microservice", + "scripts": { + "postdeploy": "pipenv run alembic upgrade head" + }, + "stack": "heroku-20" + } diff --git a/bin/qgtunnel b/bin/qgtunnel new file mode 100755 index 0000000..25f3c61 Binary files /dev/null and b/bin/qgtunnel differ diff --git a/data-full.json b/data-full.json index dc3fd55..9511584 100644 --- a/data-full.json +++ b/data-full.json @@ -1,6 +1,5 @@ { - "SENDGRID_API_KEY": "[Your sendgrid API key: https://app.sendgrid.com/settings/api_keys]", "asm": { "group_id": "[Create unsubscribe group id: https://mc.sendgrid.com/unsubscribe-groups] or leave empty", "groups_to_display": [ @@ -25,13 +24,22 @@ "filename": "sample.pdf", "path": "", "type": "text/plain" + }, + { + "filename": "test.pdf", + "path": "https://www.sf.gov/test.pdf", + "type": "application/pdf", + "headers": { + "api-key": "123ABC" + } } ], - "batch_id": "[Create sendgrid batch id: curl --request POST --url https://api.sendgrid.com/v3/mail/batch --header 'authorization: Bearer <>']", - "categories": [ - "category1", - "category2" - ], + "template": { + "url": "https://static.sf.gov/templates/mail/helloworld.html", + "replacements": { + "replacement_key": "replacement_value" + } + }, "content": [ { "type": "text/html", @@ -57,134 +65,5 @@ "email": "[Your from address]", "name": "[Your from name]" }, - "reply_to": { - "email": "[Your reply-to address]", - "name": "[Your repy-to name]" - }, - "subject": "Hello, World!", - "send_at": 1409348513, - "template_id": "[Leave this empty or specify your sendgrid dynamic template id: https://mc.sendgrid.com/dynamic-templates]", - "dynamic_template_data": { - "guest": "stuff", - "partysize": "4", - "english": true, - "subject": "test email", - "date": "April 1st, 2018" - }, - "headers": { - "X-Accept-Language": "en", - "X-Mailer": "DS Email Service" - }, - "mail_settings": { - "bcc": { - "email": "[Bcc]", - "enable": false - }, - "bypass_list_management": { - "enable": false - }, - "footer": { - "enable": true, - "html": "

Thanks
The SendGrid Team

", - "text": "Thanks,/n The SendGrid Team" - }, - "sandbox_mode": { - "enable": false - }, - "spam_check": { - "enable": false, - "post_to_url": "http://example.com/compliance", - "threshold": 3 - } - }, - "personalizations": [ - { - "to": [ - { - "email": "john@doe.com", - "name": "John Doe" - } - ], - "bcc": [ - { - "email": "sam@doe.com", - "name": "Sam Doe" - } - ], - "cc": [ - { - "email": "jane@doe.com", - "name": "Jane Doe" - } - ], - "custom_args": { - "New Argument 1": "New Value 1", - "activationAttempt": "1", - "customerAccountNumber": "[CUSTOMER ACCOUNT NUMBER GOES HERE]" - }, - "headers": { - "X-Accept-Language": "en", - "X-Mailer": "DS Email Service" - }, - "subject": "Hello, World!" - }, - { - "to": [ - { - "email": "agent1@nasa.xcom", - "name": "Agent 1" - } - ], - "bcc": [ - { - "email": "agent2@nasa.xcom", - "name": "Agent 2" - } - ], - "cc": [ - { - "email": "agent3@nasa.xcom", - "name": "Agent 3" - } - ], - "custom_args": { - "New Argument 2": "New Value 2", - "activationAttempt": "2", - "customerAccountNumber": "[CUSTOMER ACCOUNT NUMBER GOES HERE]" - }, - "headers": { - "X-Accept-Language": "en", - "X-Mailer": "DS Email Service" - }, - "subject": "Hello, World 2!" - } - ], - "sections": { - ":sectionName1": "section 1 text", - ":sectionName2": "section 2 text" - }, - "tracking_settings": { - "click_tracking": { - "enable": false, - "enable_text": false - }, - "ganalytics": { - "enable": false, - "utm_source": "[Name of the referrer source. (e.g. Google, SomeDomain.com, or Marketing Email)]", - "utm_medium": "[NAME OF YOUR MARKETING MEDIUM e.g. email]", - "utm_term": "[IDENTIFY PAID KEYWORDS HERE]", - "utm_content": "[USE THIS SPACE TO DIFFERENTIATE YOUR EMAIL FROM ADS]", - "utm_campaign": "[The name of the campaign]" - }, - "open_tracking": { - "enable": false, - "substitution_tag": "%opentrack" - }, - "subscription_tracking": { - "enable": false, - "html": "If you would like to unsubscribe and stop receiving these emails <% clickhere %>.", - "substitution_tag": "<%click here%>", - "text": "If you would like to unsubscribe and stop receiving these emails <% click here %>." - } - } - } \ No newline at end of file + "subject": "Hello, World!" + } \ No newline at end of file diff --git a/data-sample.json b/data-sample.json index c3e3059..c0b404c 100644 --- a/data-sample.json +++ b/data-sample.json @@ -1,6 +1,5 @@ { - "SENDGRID_API_KEY": "[Your sendgrid API key: https://app.sendgrid.com/settings/api_keys]", "attachments": [ { "content": "", @@ -19,6 +18,14 @@ "filename": "sample.pdf", "path": "", "type": "text/plain" + }, + { + "filename": "test.pdf", + "path": "https://www.sf.gov/test.pdf", + "type": "application/pdf", + "headers": { + "api-key": "123ABC" + } } ], "content": [ @@ -41,18 +48,5 @@ "email": "[Your from address]", "name": "[Your from name]" }, - "reply_to": { - "email": "[Your reply-to address]", - "name": "[Your repy-to name]" - }, - "subject": "Hello, World!", - "send_at": 1409348513, - "template_id": "[Leave this empty or specify your sendgrid dynamic template id: https://mc.sendgrid.com/dynamic-templates]", - "dynamic_template_data": { - "guest": "stuff", - "partysize": "4", - "english": true, - "subject": "test email", - "date": "April 1st, 2018" - } + "subject": "Hello, World!" } \ No newline at end of file diff --git a/heroku-configvar-files-buildpack-config b/heroku-configvar-files-buildpack-config new file mode 100644 index 0000000..2ee2453 --- /dev/null +++ b/heroku-configvar-files-buildpack-config @@ -0,0 +1 @@ +QUOTAGUARD_TUNNEL:.qgtunnel diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..168ea03 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,83 @@ +# pylint: skip-file +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, create_engine +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +def get_url(): + return os.getenv("DATABASE_URL") + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + url = get_url() + connectable = create_engine(url) + # connectable = engine_from_config( + # config.get_section(config.config_ini_section), + # prefix="sqlalchemy.", + # poolclass=pool.NullPool, + # ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/9375164b3f2f_create_history_table.py b/migrations/versions/9375164b3f2f_create_history_table.py new file mode 100644 index 0000000..d7043f4 --- /dev/null +++ b/migrations/versions/9375164b3f2f_create_history_table.py @@ -0,0 +1,33 @@ +# pylint: skip-file +"""create history table + +Revision ID: 9375164b3f2f +Revises: +Create Date: 2021-09-15 15:26:10.758002 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy.dialects.postgresql as postgres + + +# revision identifiers, used by Alembic. +revision = '9375164b3f2f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'history', + sa.Column('id', sa.Integer, primary_key=True, nullable=False), + sa.Column('request', postgres.JSONB, nullable=False), + sa.Column('email_content', postgres.JSONB), + sa.Column('result', sa.TEXT), + sa.Column('date_created', sa.DateTime(timezone=True)) + ) + + +def downgrade(): + op.drop_table('history') diff --git a/service/microservice.py b/service/microservice.py index 748094f..c17ba81 100755 --- a/service/microservice.py +++ b/service/microservice.py @@ -4,6 +4,7 @@ import jsend import sentry_sdk import falcon +from .resources.db import create_session from .resources.email import EmailService from .resources.welcome import Welcome @@ -14,7 +15,7 @@ def start_service(): # Initialize Sentry sentry_sdk.init(os.environ.get('SENTRY_DSN')) # Initialize Falcon - api = falcon.API() + api = falcon.App(middleware=[SQLAlchemySessionManager(create_session())]) api.add_route('/welcome', Welcome()) api.add_route('/email', EmailService()) api.add_sink(default_error, '') @@ -26,4 +27,23 @@ def default_error(_req, resp): msg_error = jsend.error('404 - Not Found') sentry_sdk.capture_message(msg_error) - resp.body = json.dumps(msg_error) + resp.text = json.dumps(msg_error) + +class SQLAlchemySessionManager: + """ + Create a session for every request and close it when the request ends. + """ + + def __init__(self, Session): + self.Session = Session # pylint: disable=invalid-name + + def process_resource(self, req, resp, resource, params): + # pylint: disable=unused-argument + """attach a db session for every resource""" + resource.session = self.Session() + + def process_response(self, req, resp, resource, req_succeeded): + # pylint: disable=no-self-use, unused-argument + """close db session for every resource""" + if hasattr(resource, 'session'): + resource.session.close() diff --git a/service/resources/db.py b/service/resources/db.py new file mode 100644 index 0000000..f8898a7 --- /dev/null +++ b/service/resources/db.py @@ -0,0 +1,26 @@ +"""db module""" +import os +import sqlalchemy as sa +import sqlalchemy.dialects.postgresql as postgres +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy.sql import func + +db_engine = sa.create_engine(os.environ.get('DATABASE_URL'), echo=True, pool_pre_ping=True) # pylint: disable=invalid-name +BASE = declarative_base() + +def create_session(expire_on_commit=True): + """creates database session""" + return sessionmaker(bind=db_engine, expire_on_commit=expire_on_commit) + +class HistoryModel(BASE): + # pylint: disable=too-few-public-methods + """Map History object to db""" + + __tablename__ = 'history' + + id = sa.Column(sa.Integer, primary_key=True) + request = sa.Column(postgres.JSONB, nullable=False) + email_content = sa.Column(postgres.JSONB) + result = sa.Column(sa.TEXT) + date_created = sa.Column(sa.DateTime(timezone=True), server_default=func.now()) diff --git a/service/resources/email.py b/service/resources/email.py index 94a45a9..441b514 100644 --- a/service/resources/email.py +++ b/service/resources/email.py @@ -1,10 +1,15 @@ """ Email """ import os import json +import traceback +import urllib.request +import mimetypes import falcon -from sendgrid import SendGridAPIClient -from sendgrid.helpers.mail import (Mail, From, Subject, TemplateId, Asm, GroupId, GroupsToDisplay, BatchId) -from python_http_client.exceptions import HTTPError +import sendgrid +from sendgrid.helpers.mail import (Mail, From, Subject, Asm, GroupId, GroupsToDisplay) +from jinja2 import Template +from bs4 import BeautifulSoup +from service.resources.db import HistoryModel from .helpers.helpers import HelperService from .hooks import validate_access @@ -13,16 +18,24 @@ class EmailService(): """ Email service """ def on_post(self, req, resp): """ Implement POST """ - data = json.loads(req.stream.read()) + # pylint: disable=broad-except,no-member + history_event = HistoryModel() + try: + data = json.loads(req.bounded_stream.read()) + history_event.request = data + + history_event.email_content = self.send_email(data, resp) + history_event.result = resp.text + except Exception as error: + print(f"EmailService exception: {error}") + print(traceback.format_exc()) + resp.status = falcon.HTTP_500 # pylint: disable=no-member + resp.text = json.dumps(str(error)) - if 'personalizations' in data.keys() and data['personalizations'] is not None: - for personalization in data['personalizations']: - # map fields from personalization to main email """ - for p_key, p_value in personalization.items(): - data[p_key] = p_value - self.send_email(data, resp) - else: - self.send_email(data, resp) + history_event.result = resp.text + finally: + self.session.add(history_event) + self.session.commit() @staticmethod def send_email(data, resp): @@ -34,67 +47,72 @@ def send_email(data, resp): message.from_email = From(data['from']['email'], data['from']['name']) message.subject = Subject(data['subject']) - if 'send_at' in data.keys() and data['send_at'] != '': - message.send_at = data['send_at'] if 'asm' in data.keys() and data['asm'] is not None and data['asm']['group_id'] != '': message.asm = Asm(GroupId(data['asm']['group_id']), - GroupsToDisplay(data['asm']['groups_to_display'])) - - if 'batch_id' in data.keys() and data['batch_id'] != '': - message.batch_id = BatchId(data['batch_id']) - - #If template id is specified, set dynamic data for the template - if 'template_id' in data.keys() and data['template_id'] != "": - message.template_id = TemplateId(data['template_id']) - template_data = {} - template_data.update(data['dynamic_template_data']) - message.dynamic_template_data = template_data + GroupsToDisplay(data['asm']['groups_to_display'])) func_switcher = { "to": HelperService.get_emails, "cc": HelperService.get_emails, "bcc": HelperService.get_emails, "content": HelperService.get_content, - "attachment": HelperService.get_attachments, - "tracking_settings": HelperService.get_email_trackings, - "custom_arg": HelperService.get_custom_args, - "section": HelperService.get_sections, - "header": HelperService.get_headers, - "category": HelperService.get_category + "attachments": HelperService.get_attachments, + "custom_args": HelperService.get_custom_args } message.to = func_switcher.get("to")(data['to'], 'to') - if 'cc' in data.keys(): + data_keys = data.keys() + if 'cc' in data_keys: message.cc = func_switcher.get("cc")(data['cc'], 'cc') - if 'bcc' in data.keys(): + if 'bcc' in data_keys: message.bcc = func_switcher.get("bcc")(data['bcc'], 'bcc') - if 'content' in data.keys(): + if 'template' in data_keys and not 'content' in data_keys: + data['content'] = generate_template_content(data['template']) + data_keys = data.keys() + if 'content' in data_keys: message.content = func_switcher.get("content")(data['content']) - if 'attachment' in data.keys(): - message.attachment = func_switcher.get("attachment")(data['attachments']) - if 'tracking_settings' in data.keys(): - message.tracking_settings = func_switcher.get("tracking_settings")(data['tracking_settings']) - if 'custom_arg' in data.keys(): - message.custom_arg = func_switcher.get("custom_arg")(data['custom_args']) - if 'section' in data.keys(): - message.section = func_switcher.get("section")(data['sections']) - if 'header' in data.keys(): - message.header = func_switcher.get("header")(data['headers']) - if 'category' in data.keys(): - message.category = func_switcher.get("category")(data['categories']) - - #pylint: disable=broad-except - try: - #logging.warning(message.get()) - sendgrid_api_key = os.environ.get('SENDGRID_API_KEY') - - sendgrid_client = SendGridAPIClient(sendgrid_api_key) - response = sendgrid_client.send(message) - resp.body = response.body - resp.status = falcon.HTTP_200 - except HTTPError as error: - print("error") - print(error.to_dict) - except Exception as error: - print("exception") - resp.body = json.dumps(str(error)) + if 'attachments' in data_keys: + message.attachment = func_switcher.get("attachments")(data['attachments']) + if 'custom_args' in data_keys: + message.custom_arg = func_switcher.get("custom_args")(data['custom_args']) + + #logging.warning(message.get()) + sendgrid_client = sendgrid.SendGridAPIClient(api_key=os.environ.get('SENDGRID_API_KEY')) + response = sendgrid_client.send(message) + + print(f"response: {response.body}") + print(f"status: {response.status_code}") + resp.text = response.body + resp.status = falcon.HTTP_200 # pylint: disable=no-member + + return [c.get() for c in message.contents] + +def generate_template_content(template_params): + """ generate array of html/plain text content from template """ + result = [] + + # url and replacements are required + if 'url' not in template_params: + raise KeyError('url value is required for email template') + if 'replacements' not in template_params: + raise KeyError('replacement values are required for email template') + + with urllib.request.urlopen(template_params['url']) as conn: + template_content = conn.read() + if not isinstance(template_content, str): + template_content = template_content.decode("utf-8") + template = Template(template_content) + html_content = template.render(template_params['replacements']) + + result.append({ + "type": mimetypes.types_map['.html'], + "value": html_content + }) + + soup = BeautifulSoup(html_content, features="html.parser") + result.append({ + "type": mimetypes.types_map['.txt'], + "value": soup.get_text() + }) + + return result diff --git a/service/resources/helpers/helpers.py b/service/resources/helpers/helpers.py index 8efe5fd..2ca78a7 100644 --- a/service/resources/helpers/helpers.py +++ b/service/resources/helpers/helpers.py @@ -2,36 +2,29 @@ Set of helper functions to handler sendgrid email api options """ import base64 +import urllib.request from sendgrid.helpers.mail import ( - To, Cc, Bcc, Header, + To, Cc, Bcc, CustomArg, Content, Attachment, FileName, - FileContent, FileType, - Section, Category, MailSettings, BccSettings, BccSettingsEmail, - BypassListManagement, FooterSettings, FooterText, - FooterHtml, SandBoxMode, SpamCheck, SpamThreshold, SpamUrl, - TrackingSettings, ClickTracking, SubscriptionTracking, - SubscriptionText, SubscriptionHtml, SubscriptionSubstitutionTag, - OpenTracking, OpenTrackingSubstitutionTag, Ganalytics, - UtmSource, UtmMedium, UtmTerm, UtmContent, UtmCampaign) + FileContent, FileType) class HelperService(): """ Helper class for sendgrid options """ @staticmethod def get_attachments(attachments): """ This function sets up email attachments """ - try: - # Python 3 - import urllib.request as urllib # pylint: disable=C - except ImportError: - # Python 2 - import urllib2 as urllib # pylint: disable=C attachment_list = [] for attachment in attachments: if 'path' in attachment.keys() and attachment['path'] is not None and attachment['path'] != "": file_path = attachment['path'] - data = urllib.urlopen(file_path).read() - encoded = base64.b64encode(data).decode() + req = urllib.request.Request(file_path) + if attachment.get('headers'): + for header, header_value in attachment.get('headers').items(): + req.add_header(header, header_value) + with urllib.request.urlopen(req) as conn: + data = conn.read() + encoded = base64.b64encode(data).decode() else: encoded = base64.b64encode(bytes(attachment['content'], 'utf-8')).decode() @@ -42,35 +35,6 @@ def get_attachments(attachments): )) return attachment_list - @staticmethod - def get_email_trackings(settings): - """ This function sets up email trackings """ - tracking_settings = TrackingSettings() - if 'click_tracking' in settings.keys() and settings['click_tracking'] is not None: - tracking_settings.click_tracking = ClickTracking( - settings['click_tracking']['enable'], - settings['click_tracking']['enable_text']) - if 'open_tracking' in settings.keys() and settings['open_tracking'] is not None: - tracking_settings.open_tracking = OpenTracking( - settings['open_tracking']['enable'], - OpenTrackingSubstitutionTag(settings['open_tracking']['substitution_tag']) - ) - if 'subscription_tracking' in settings.keys() and settings['subscription_tracking'] is not None: - tracking_settings.subscription_tracking = SubscriptionTracking( - settings['subscription_tracking']['enable'], - SubscriptionText(settings['subscription_tracking']['text']), - SubscriptionHtml(settings['subscription_tracking']['html']), - SubscriptionSubstitutionTag(settings['subscription_tracking']['substitution_tag'])) - if 'ganalytics' in settings.keys() and settings['ganalytics'] is not None: - tracking_settings.ganalytics = Ganalytics( - settings['ganalytics']['enable'], - UtmSource(settings['ganalytics']['utm_source']), - UtmMedium(settings['ganalytics']['utm_medium']), - UtmTerm(settings['ganalytics']['utm_term']), - UtmContent(settings['ganalytics']['utm_content']), - UtmCampaign(settings['ganalytics']['utm_campaign']) - ) - return tracking_settings @staticmethod def get_custom_args(custom_args): @@ -80,58 +44,6 @@ def get_custom_args(custom_args): custom_arg_list.append(CustomArg(custom_arg_key, custom_arg_value)) return custom_arg_list - @staticmethod - def get_mail_settings(settings): - """ This function sets up email settings """ - mail_settings = MailSettings() - if 'bcc' in settings.keys() and settings['bcc'] is not None: - mail_settings.bcc_settings = BccSettings( - settings['bcc']['enable'], - BccSettingsEmail(settings['bcc']['email']) - ) - if 'bypass_list_management' in settings.keys() and settings['bypass_list_management'] is not None: - mail_settings.bypass_list_management = BypassListManagement( - settings['bypass_list_management']['enable']) - if 'footer' in settings.keys() and settings['footer'] is not None: - mail_settings.footer_settings = FooterSettings( - settings['footer']['enable'], - FooterText(settings['footer']['text']), - FooterHtml(settings['footer']['html']) - ) - if 'sandbox_mode' in settings.keys() and settings['sandbox_mode'] is not None: - mail_settings.sandbox_mode = SandBoxMode(settings['sandbox_mode']['enable']) - if 'spam_check' in settings.keys() and settings['spam_check'] is not None: - mail_settings.spam_check = SpamCheck( - settings['spam_check']['enable'], - SpamThreshold(settings['spam_check']['threshold']), - SpamUrl(settings['spam_check']['post_to_url']) - ) - return mail_settings - - @staticmethod - def get_sections(sections): - """ This function sets up email Sections """ - email_sections = [] - for section_key, section_value in sections.items(): - email_sections.append(Section(section_key, section_value)) - return email_sections - - @staticmethod - def get_headers(headers): - """ This function sets up email headers """ - email_headers = [] - for header_key, header_value in headers.items(): - email_headers.append(Header(header_key, header_value)) - return email_headers - - @staticmethod - def get_category(categories): - """ This function sets up email Categories """ - email_categories = [] - for category in categories: - email_categories.append(Category(category)) - return email_categories - @staticmethod def get_emails(emails, email_type): """ This function sets up email type """ diff --git a/service/resources/welcome.py b/service/resources/welcome.py index 173920b..42c6179 100755 --- a/service/resources/welcome.py +++ b/service/resources/welcome.py @@ -14,5 +14,5 @@ def on_get(self, _req, resp): return Welcome message """ msg = {'message': 'Welcome'} - resp.body = json.dumps(jsend.success(msg)) + resp.text = json.dumps(jsend.success(msg)) resp.status = falcon.HTTP_200 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 0000000..1d8d6ff --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,54 @@ +""" mocks """ + +EMAIL_POST = { + "subject": "unit test", + "to": [{ + "email": "recipient@sf.gov", + "name": "recipient" + }], + "from": { + "email": "sender@sf.gov", + "name": "sender" + }, + "content": [{ + "type": "text/plain", + "value": "Hello world!" + }], + "attachments": [{ + "content": "YmFzZTY0IHN0cmluZw==", + "filename": "file1.txt", + "type": "text/plain" + },{ + "filename": "test.pdf", + "path": "https://www.sf.gov/test.pdf", + "type": "application/pdf", + "headers": { + "api-key": "123ABC" + } + }], + "cc": [{ + "email": "cc-recipient@sf.gov", + "name": "cc-recipient" + }], + "bcc": [{ + "email": "bcc-recipient@sf.gov", + "name": "bcc-recipient" + }], + "custom_args": { + "foo": "bar", + "hello": "world" + }, + "asm": { + "group_id": 1, + "groups_to_display": [1, 2] + } +} + +TEMPLATE_PARAMS = { + "url": "", + "replacements": { + "what_knights_say": "ni" + } + } + +EMAIL_HTML = "

Knights that say {{ what_knights_say }}!

" diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..b192d24 --- /dev/null +++ b/tests/test_email.py @@ -0,0 +1,28 @@ +""" Tests for email module """ +from unittest.mock import patch +import pytest +from tests import mocks +from service.resources.email import generate_template_content + +@patch('urllib.request.urlopen') +def test_generate_template_content(mock_urlopen): + """ test generate_template_content function """ + + # Required parameters + with pytest.raises(KeyError): + generate_template_content({'url': 'https://www.foo.com'}) + with pytest.raises(KeyError): + generate_template_content({'replacements': {'foo': 'bar'}}) + + mock_urlopen.return_value.__enter__.return_value.read.return_value = str.encode(mocks.EMAIL_HTML) + results = generate_template_content({ + 'url': 'https://some.place.com', + 'replacements': { + 'what_knights_say': 'ni' + } + }) + + assert results[0]['type'] == 'text/html' + assert results[0]['value'] == '

Knights that say ni!

' + assert results[1]['type'] == 'text/plain' + assert results[1]['value'] == 'Knights that say ni!' diff --git a/tests/test_service.py b/tests/test_service.py index 5adaee0..30c64f4 100755 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,10 +1,13 @@ -# pylint: disable=redefined-outer-name +# pylint: disable=redefined-outer-name,no-member,unused-argument """Tests for microservice""" import json +from unittest.mock import patch import jsend import pytest +import falcon from falcon import testing import service.microservice +from tests import mocks CLIENT_HEADERS = { "ACCESS_KEY": "1234567" @@ -19,6 +22,7 @@ def client(): def mock_env_access_key(monkeypatch): """ mock environment access key """ monkeypatch.setenv("ACCESS_KEY", CLIENT_HEADERS["ACCESS_KEY"]) + monkeypatch.setenv("SENDGRID_API_KEY", "abc123") @pytest.fixture def mock_env_no_access_key(monkeypatch): @@ -57,3 +61,44 @@ def test_default_error(client, mock_env_access_key): expected_msg_error = jsend.error('404 - Not Found') assert json.loads(response.content) == expected_msg_error + +@patch('urllib.request.urlopen') +@patch('sendgrid.SendGridAPIClient') +def test_email(mock_sendgrid_client, mock_urlopen, client, mock_env_access_key): + """Test email endpoint""" + print("test_email") + mock_sendgrid_client.return_value.send.return_value.body = "sendgrid response goes here" + mock_sendgrid_client.return_value.send.return_value.status = 200 + mock_urlopen.return_value.__enter__.return_value.read.return_value = b"fake_data" + + response = client.simulate_post('/email', json=mocks.EMAIL_POST) + + assert response.status == falcon.HTTP_200 + +@patch('urllib.request.urlopen') +@patch('sendgrid.SendGridAPIClient') +def test_email_template(mock_sendgrid_client, mock_urlopen, client, mock_env_access_key): + """Test email endpoint""" + print("test_email_template") + mock_sendgrid_client.return_value.send.return_value.body = "sendgrid response goes here" + mock_sendgrid_client.return_value.send.return_value.status = 200 + mock_urlopen.return_value.__enter__.return_value.read.side_effect = [mocks.EMAIL_HTML, b"fake_data", b"fake_data"] + + params = mocks.EMAIL_POST.copy() + del params['content'] + params['template'] = mocks.TEMPLATE_PARAMS + response = client.simulate_post('/email', json=params) + + assert response.status == falcon.HTTP_200 + +@patch('urllib.request.urlopen') +@patch('sendgrid.SendGridAPIClient') +def test_email_error(mock_sendgrid_client, mock_urlopen, client, mock_env_access_key): + """Test email endpoint""" + print("test_email_error") + mock_sendgrid_client.return_value.send.side_effect = Exception("Error!") + mock_urlopen.return_value.__enter__.return_value.read.return_value = b"fake_data" + + response = client.simulate_post('/email', json=mocks.EMAIL_POST) + + assert response.status == falcon.HTTP_500 diff --git a/vendor/nss_wrapper/COPYING b/vendor/nss_wrapper/COPYING new file mode 100644 index 0000000..0100415 --- /dev/null +++ b/vendor/nss_wrapper/COPYING @@ -0,0 +1,32 @@ +Copyright (C) Stefan Metzmacher 2007 +Copyright (C) Guenther Deschner 2009 +Copyright (C) Andreas Schneider 2013 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/vendor/nss_wrapper/libnss_wrapper.so b/vendor/nss_wrapper/libnss_wrapper.so new file mode 100755 index 0000000..f72eda5 Binary files /dev/null and b/vendor/nss_wrapper/libnss_wrapper.so differ