diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a6b5bcfaf..05277c856 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -49,4 +49,4 @@ jobs: mkdir -p client/dist/static pipenv run python server/manage.py collectstatic pipenv run pytest --mccabe --cov=my_project -vv server/my_project - pipenv run coverage report --fail-under=20 + pipenv run coverage report --fail-under=60 diff --git a/Pipfile b/Pipfile index bacfbd13b..5c72b7f9b 100644 --- a/Pipfile +++ b/Pipfile @@ -18,7 +18,6 @@ pytest-instafail = "==0.4.2" PyYAML = "==6.0.1" PyGithub = "==1.55" Jinja2 = "==3.0.1" -jinja2-time = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index c863cf9ba..4beeddd21 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7edf08a5a1f3deb5b32e4ef8e2be3f940bab4365bb0760123c19e3447b7df41e" + "sha256": "3c02ac2e9b7299816568e55a5e3c6743ab5eb72515b74c0f00577a50f8105a08" }, "pipfile-spec": 6, "requires": { @@ -26,11 +26,11 @@ }, "attrs": { "hashes": [ - "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", - "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", + "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" ], "markers": "python_version >= '3.7'", - "version": "==23.2.0" + "version": "==24.2.0" }, "binaryornot": { "hashes": [ @@ -72,69 +72,84 @@ }, "certifi": { "hashes": [ - "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516", - "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56" + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" ], "markers": "python_version >= '3.6'", - "version": "==2024.6.2" + "version": "==2024.8.30" }, "cffi": { "hashes": [ - "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", - "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", - "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", - "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", - "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", - "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", - "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", - "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", - "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", - "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", - "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", - "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", - "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", - "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", - "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", - "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", - "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", - "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", - "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", - "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", - "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", - "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", - "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", - "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", - "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", - "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", - "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", - "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", - "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", - "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", - "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", - "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", - "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", - "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", - "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", - "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", - "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", - "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", - "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", - "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", - "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", - "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", - "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", - "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", - "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", - "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", - "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", - "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", - "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", - "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", - "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", - "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" ], "markers": "python_version >= '3.8'", - "version": "==1.16.0" + "version": "==1.17.1" }, "cfgv": { "hashes": [ @@ -282,11 +297,11 @@ }, "filelock": { "hashes": [ - "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f", - "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a" + "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", + "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7" ], "markers": "python_version >= '3.8'", - "version": "==3.14.0" + "version": "==3.15.4" }, "flake8": { "hashes": [ @@ -307,19 +322,19 @@ }, "identify": { "hashes": [ - "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa", - "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d" + "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf", + "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0" ], "markers": "python_version >= '3.8'", - "version": "==2.5.36" + "version": "==2.6.0" }, "idna": { "hashes": [ - "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", - "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" + "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", + "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" ], - "markers": "python_version >= '3.5'", - "version": "==3.7" + "markers": "python_version >= '3.6'", + "version": "==3.8" }, "iniconfig": { "hashes": [ @@ -352,7 +367,6 @@ "sha256:d14eaa4d315e7688daa4969f616f226614350c48730bfa1692d2caebd8c90d40", "sha256:d3eab6605e3ec8b7a0863df09cc1d23714908fa61aa6986a845c20ba488b4efa" ], - "index": "pypi", "version": "==0.2.0" }, "markupsafe": { @@ -528,11 +542,11 @@ }, "pyjwt": { "hashes": [ - "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", - "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320" + "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", + "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c" ], - "markers": "python_version >= '3.7'", - "version": "==2.8.0" + "markers": "python_version >= '3.8'", + "version": "==2.9.0" }, "pynacl": { "hashes": [ @@ -708,27 +722,27 @@ }, "types-python-dateutil": { "hashes": [ - "sha256:5d2f2e240b86905e40944dd787db6da9263f0deabef1076ddaed797351ec0202", - "sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b" + "sha256:9649d1dcb6fef1046fb18bebe9ea2aa0028b160918518c34589a46045f6ebd98", + "sha256:f5889fcb4e63ed4aaa379b44f93c32593d50b9a94c9a60a0c854d8cc3511cd57" ], "markers": "python_version >= '3.8'", - "version": "==2.9.0.20240316" + "version": "==2.9.0.20240821" }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", + "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.2" }, "virtualenv": { "hashes": [ - "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c", - "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b" + "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", + "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589" ], "markers": "python_version >= '3.7'", - "version": "==20.26.2" + "version": "==20.26.3" }, "wrapt": { "hashes": [ diff --git a/README.md b/README.md index 3b20cb157..a7da5a6b5 100644 --- a/README.md +++ b/README.md @@ -6,45 +6,41 @@ A production-ready Django SPA app on Heroku in 20-min or less! ## Quick Start -First, get cookiecutter, as detailed in the [official documentation](https://cookiecutter.readthedocs.io/en/stable/installation.html#install-cookiecutter). +First, get `pipx` for your system, if you don't already have it [installing pipx](https://pipx.pypa.io/stable/installation/#installing-pipx). - -You will also need some of the libraries being used to generate your artifacts - -`python -m pip install Jinja2 jinja2-time` - -Now run it against this repo: +Adn run the following command: ```bash -cookiecutter git@github.com:thinknimble/tn-spa-bootstrapper.git +pipx install cookiecutter +pipx run cookiecutter gh:thinknimble/tn-spa-bootstrapper ``` ## Features See: [Maintained Foundation fork] - - For Django 3.1 - - Uses Python 3.10 by default - - Renders Django projects with 100% starting test coverage - - Secure by default. We believe in SSL. - - Optimized development and production settings - - Comes with custom user model ready to go - - Optional basic ASGI setup for Websockets - - Optional basic Django channel setup for Websockets - - Optional client side applications Vue or React - - Send emails using [Mailgun] by default or Amazon SES if AWS is selected cloud provider. - - Media storage using Amazon S3 or Google Cloud Storage - - [Procfile] for deploying to Heroku - - Run tests with unittest or pytest - - Default integration with [pre-commit] for identifying simple issues before submission to code review - - Integration with [Rollbar] for error logging +- For Django 3.1 +- Uses Python 3.10 by default +- Renders Django projects with 100% starting test coverage +- Secure by default. We believe in SSL. +- Optimized development and production settings +- Comes with custom user model ready to go +- Optional basic ASGI setup for Websockets +- Optional basic Django channel setup for Websockets +- Optional client side applications Vue or React +- Send emails using [Mailgun] by default or Amazon SES if AWS is selected cloud provider. +- Media storage using Amazon S3 or Google Cloud Storage +- [Procfile] for deploying to Heroku +- Run tests with unittest or pytest +- Default integration with [pre-commit] for identifying simple issues before submission to code review +- Integration with [Rollbar] for error logging ## Optional Integrations These features can be enabled after initial project setup: - - Serve static files from Amazon S3 or Whitenoise - - Integration with [MailHog] for local email testing +- Serve static files from Amazon S3 or Whitenoise +- Integration with [MailHog] for local email testing ## Usage @@ -71,22 +67,22 @@ Answer the prompts with your own desired options. For example: 3 - None Choose from 1, 2, 3 [1]: 1 Error: "my_project" directory already exists - william@Williams-MacBook-Pro thinknimble % rm -rf my_project + william@Williams-MacBook-Pro thinknimble % rm -rf my_project william@Williams-MacBook-Pro thinknimble % cookiecutter git@github.com:thinknimble/tn-spa-cookiecutter.git --checkout cleanup - You've downloaded /Users/william/.cookiecutters/tn-spa-cookiecutter before. Is it okay to delete and re-download it? [yes]: - project_name [My Project]: - author_name [ThinkNimble]: - email [hello@thinknimble.com]: - project_slug [my_project]: + You've downloaded /Users/william/.cookiecutters/tn-spa-cookiecutter before. Is it okay to delete and re-download it? [yes]: + project_name [My Project]: + author_name [ThinkNimble]: + email [hello@thinknimble.com]: + project_slug [my_project]: Select mail_service: 1 - Mailgun 2 - Amazon SES 3 - Custom SMTP - Choose from 1, 2, 3 [1]: + Choose from 1, 2, 3 [1]: Select client_app: 1 - Vue3 2 - None - Choose from 1, 2 [1]: + Choose from 1, 2 [1]: Create a git repo and push it there:: @@ -95,7 +91,7 @@ git init git add . git commit -m "first awesome commit" git remote set-url origin git@github.com:thinknimble/the-rock.git -git push -u origin main +git push -u origin main ``` Now take a look at your repo. Don't forget to carefully look at the generated README. Awesome, right? diff --git a/cookiecutter.json b/cookiecutter.json index ac51bf314..ccf24a1db 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -4,7 +4,6 @@ "email": "hello@thinknimble.com", "project_slug": "{{ cookiecutter.project_name.lower()|replace(' ', '_')|replace('-', '_')|replace('.', '_')|trim() }}", "_extensions": [ - "jinja2_time.TimeExtension", "cookiecutter.extensions.RandomStringExtension" ], "_copy_without_render": [ diff --git a/{{cookiecutter.project_slug}}/pytest.ini b/{{cookiecutter.project_slug}}/pytest.ini index 8eed4bed2..e947626ee 100644 --- a/{{cookiecutter.project_slug}}/pytest.ini +++ b/{{cookiecutter.project_slug}}/pytest.ini @@ -2,7 +2,7 @@ DJANGO_SETTINGS_MODULE = {{ cookiecutter.project_slug }}.test_settings python_files = tests.py test_*.py *_tests.py addopts = --strict-markers --no-migrations -mccabe-complexity=10 +mccabe-complexity=8 filterwarnings = ignore::DeprecationWarning diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py index 9f4b0ac93..44edb9e2c 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py @@ -16,4 +16,4 @@ class Meta: abstract = True def __str__(self): - return "ah yes" + return "__str__ not defined for this model" diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py index 71a55e02d..27490cb92 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py @@ -24,7 +24,7 @@ class CustomUserAdmin(UserAdmin): ) }, ), - ("Admin Options", {"classes": ("collapse",), "fields": ("is_staff", "groups")}), + ("Admin Options", {"classes": ("collapse",), "fields": ("is_active", "is_staff", "is_superuser", "groups")}), ) add_fieldsets = ( ( @@ -35,7 +35,15 @@ class CustomUserAdmin(UserAdmin): }, ), ) - list_display = ("email", "first_name", "last_name", "is_active", "is_staff", "is_superuser", "permissions") + list_display = ( + "email", + "permissions", + "is_active", + "is_staff", + "is_superuser", + "first_name", + "last_name", + ) list_display_links = ( "is_active", "email", @@ -57,7 +65,7 @@ class CustomUserAdmin(UserAdmin): ordering = [] def permissions(self, obj): - return ", ".join([g.name for g in obj.groups.all()]) + return [g.name for g in obj.groups.all()] class Media(AutocompleteAdminMedia): pass diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py index 33f9bc7dc..b5072fc54 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py @@ -7,6 +7,8 @@ class UserFactory(factory.Factory): email = factory.faker.Faker("email") password = factory.PostGenerationMethodCall("set_password", "password") + first_name = factory.faker.Faker("first_name") + last_name = factory.faker.Faker("last_name") class Meta: model = User diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py index c7cceee33..e803538e2 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py @@ -11,11 +11,26 @@ logger = logging.getLogger(__name__) +class UserQuerySet(models.QuerySet): + def for_user(self, user): + if not user or user.is_anonymous: + return self.none() + elif user.is_staff: + return self.all() + return self.filter(pk=user.pk) + + class UserManager(BaseUserManager): """Custom User model manager, eliminating the 'username' field.""" use_in_migrations = True + def get_queryset(self): + return UserQuerySet(self.model, using=self.db) + + def for_user(self, user): + return self.get_queryset().for_user(user) + def _create_user(self, email, password, **extra_fields): """ Create and save a User with the given email and password. diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py index eb6a2282c..0e67f48b4 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py @@ -1,8 +1,8 @@ from rest_framework import permissions -class CreateOnlyPermissions(permissions.BasePermission): - def has_permission(self, request, view): - if view.action == "create": - return True - return False +class HasUserPermissions(permissions.BasePermission): + """Admins should be able to perform any action, regular users should be able to edit and delete self.""" + + def has_object_permission(self, request, view, obj): + return request.user.is_authenticated and (request.user.is_staff or obj == request.user) diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py index 27dbc64e3..723f88479 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py @@ -16,6 +16,7 @@ class Meta: "last_name", "full_name", ) + read_only_fields = ["email"] class UserLoginSerializer(serializers.ModelSerializer): diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py index e3cc149af..fb054748a 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py @@ -4,6 +4,7 @@ from django.contrib.auth import authenticate from django.test import override_settings from django.test.client import RequestFactory +from rest_framework import status from rest_framework.response import Response from .models import User @@ -34,6 +35,13 @@ def test_create_user(): assert not user.is_superuser +@pytest.mark.django_db +def test_create_user_api(api_client): + data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"} + res = api_client.post("/api/users/", data, format="json") + assert res.status_code == status.HTTP_201_CREATED, res.data + + @pytest.mark.django_db def test_create_superuser(): superuser = User.objects.create_superuser(email="test@example.com", password="password", first_name="Leslie", last_name="Burke") @@ -50,7 +58,59 @@ def test_create_user_from_factory(sample_user): @pytest.mark.django_db def test_user_can_login(api_client, sample_user): res = api_client.post("/api/login/", {"email": sample_user.email, "password": "password"}, format="json") - assert res.status_code == 200 + assert res.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_wrong_email(api_client, sample_user): + res = api_client.post("/api/login/", {"email": "wrong@example.com", "password": "password"}, format="json") + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_wrong_password(api_client, sample_user): + res = api_client.post("/api/login/", {"email": sample_user.email, "password": "wrong"}, format="json") + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_get_user(api_client, sample_user): + api_client.force_authenticate(sample_user) + res = api_client.get(f"/api/users/{sample_user.pk}/") + assert res.status_code == status.HTTP_200_OK + assert res.data["email"] == sample_user.email + + +@pytest.mark.django_db +def test_get_other_user(api_client, sample_user, user_factory): + api_client.force_authenticate(sample_user) + other_user = user_factory() + other_user.save() + res = api_client.get(f"/api/users/{other_user.pk}/") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_update_user(api_client, sample_user): + existing_email = sample_user.email + api_client.force_authenticate(sample_user) + data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"} + res = api_client.put(f"/api/users/{sample_user.pk}/", data, format="json") + assert res.status_code == status.HTTP_200_OK + sample_user.refresh_from_db() + # Email should NOT have changed + assert sample_user.email == existing_email + assert sample_user.first_name == data["first_name"] == res.data["first_name"] + assert sample_user.last_name == data["last_name"] == res.data["last_name"] + + +@pytest.mark.django_db +def test_delete_user(api_client, sample_user): + api_client.force_authenticate(sample_user) + res = api_client.delete(f"/api/users/{sample_user.pk}/") + assert res.status_code == status.HTTP_204_NO_CONTENT + sample_user.refresh_from_db() + assert sample_user.is_active is False @pytest.mark.use_requests @@ -67,7 +127,7 @@ def test_password_reset(caplog, api_client, sample_user): # Verify the link works for reseting the password response = api_client.post(password_reset_url, data={"password": "new_password"}, format="json") - assert response.status_code == 200 + assert response.status_code == status.HTTP_200_OK # New Password should now work for authentication serializer = UserLoginSerializer(data={"email": sample_user.email, "password": "new_password"}) @@ -87,7 +147,7 @@ class TestPreviewTemplateView: @override_settings(DEBUG=False) def test_disabled_if_not_debug(self, client): response = client.post(self.url) - assert response.status_code == 404 + assert response.status_code == status.HTTP_404_NOT_FOUND @override_settings(DEBUG=True) def test_enabled_if_debug(self, client): @@ -98,19 +158,19 @@ def test_enabled_if_debug(self, client): @override_settings(DEBUG=True) def test_no_template_provided(self, client): response = client.post(self.url, data={"_send_to": "someone@example.com"}) - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST assert any("You must provide a template name" in e for e in response.json()) @override_settings(DEBUG=True) def test_invalid_template_provided(self, client): response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST", data={"_send_to": "someone@example.com"}) - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST assert any("Invalid template name" in e for e in response.json()) @override_settings(DEBUG=True) def test_missing_send_to(self, client): response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST") - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST assert "This field is required." in response.json()["_send_to"] def test_parse_value_without_model(self): diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py index 42b4ce05b..b6365da38 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py @@ -18,11 +18,11 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from {{ cookiecutter.project_slug }}.core.forms import PreviewTemplateForm from {{ cookiecutter.project_slug }}.utils.emails import send_html_email +from .forms import PreviewTemplateForm from .models import User -from .permissions import CreateOnlyPermissions +from .permissions import HasUserPermissions from .serializers import UserLoginSerializer, UserRegistrationSerializer, UserSerializer logger = logging.getLogger(__name__) @@ -50,45 +50,45 @@ def post(self, request, *args, **kwargs): return Response(response_data) -class UserViewSet( - viewsets.GenericViewSet, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, -): - queryset = User.objects.all() +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + queryset = User.objects serializer_class = UserSerializer # No auth required to create user # Auth required for all other actions - permission_classes = (permissions.IsAuthenticated | CreateOnlyPermissions,) + permission_classes = (HasUserPermissions,) - @transaction.atomic - def create(self, request, *args, **kwargs): + def get_queryset(self): """ - Endpoint to create/register a new user. + Users should only find themselves by default """ + return super().get_queryset().for_user(self.request.user) + + @transaction.atomic + def create(self, request, *args, **kwargs): serializer = UserRegistrationSerializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() # This calls .create() on serializer - user = serializer.instance - + user = serializer.save() # Log-in user and re-serialize response response_data = UserLoginSerializer.login(user, request) return Response(response_data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): - """ - Endpoint to create/register a new user. - """ serializer = UserSerializer(data=request.data, instance=self.get_object(), partial=True) - serializer.is_valid(raise_exception=True) serializer.save() - user = serializer.data + return Response(serializer.data, status=status.HTTP_200_OK) - return Response(user, status=status.HTTP_200_OK) + def destroy(self, request, *args, **kwargs): + """ + When deleting a user's account, just disable their account first + The user may have a regret and try to get their account back + A background job should then properly delete the data after X days + """ + user = self.get_object() + user.is_active = False + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) @api_view(["post"]) @@ -117,7 +117,7 @@ def request_reset_link(request, *args, **kwargs): def reset_password(request, *args, **kwargs): user_id = kwargs.get("uid") token = kwargs.get("token") - user = User.objects.filter(id=user_id).first() + user = User.objects.filter(pk=user_id).first() if not user or not token: raise ValidationError(detail={"non-field-error": "Invalid or expired token"}) is_valid = default_token_generator.check_token(user, token) diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py index be4c00973..db7c19047 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py @@ -38,6 +38,9 @@ if CURRENT_DOMAIN not in ALLOWED_HOSTS: ALLOWED_HOSTS.append(CURRENT_DOMAIN) +# Used by the corsheaders app/middleware (django-cors-headers) to allow multiple domains to access the backend +CORS_ALLOWED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS] + # Application definition INSTALLED_APPS = [ @@ -306,7 +309,6 @@ if not IN_DEV: SECURE_SSL_REDIRECT = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") - MIDDLEWARE += ["django.middleware.security.SecurityMiddleware"] # # Custom logging configuration @@ -390,11 +392,6 @@ def filter(self, record): # Popular testing framework that allows logging to stdout while running unit tests TEST_RUNNER = "django_nose.NoseTestSuiteRunner" -CORS_ALLOWED_ORIGINS = ["https://{{ cookiecutter.project_slug|replace('_', '-') }}-staging.herokuapp.com", "https://{{ cookiecutter.project_slug|replace('_', '-') }}.herokuapp.com"] -{% if cookiecutter.client_app.lower() != 'none' -%} -CORS_ALLOWED_ORIGINS.append("http://localhost:8080") -{% endif -%} - SWAGGER_SETTINGS = { "LOGIN_URL": "/login", "USE_SESSION_AUTH": False, diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py index 0d2d545f3..c6b2f64ea 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py @@ -1,7 +1,7 @@ from decouple import config -from {{ cookiecutter.project_slug }}.settings import LOGGING from {{ cookiecutter.project_slug }}.settings import * # noqa +from {{ cookiecutter.project_slug }}.settings import LOGGING # Override staticfiles setting to avoid cache issues with whitenoise Manifest staticfiles storage # See: https://stackoverflow.com/a/69123932 diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py index 3b962cf51..bf86ab21e 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py @@ -1,6 +1,9 @@ from django.contrib import admin from django.urls import include, path +admin.site.site_header = "{{ cookiecutter.project_name }} Admin" +admin.site.site_title = "{{ cookiecutter.project_name }}" + urlpatterns = [ path(r"staff/", admin.site.urls), path(r"", include("{{ cookiecutter.project_slug }}.common.favicon_urls")),