diff --git a/Pipfile b/Pipfile index 7535765..602da88 100644 --- a/Pipfile +++ b/Pipfile @@ -26,6 +26,7 @@ sentry-sdk = "*" django-localflavor = "*" django-analytical = "*" django-simple-captcha = "*" +stripe = "*" [dev-packages] django-debug-toolbar = "*" diff --git a/Pipfile.lock b/Pipfile.lock index f918c40..ef9c21b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e73567e23f2b566826ba328a2b92091773c433c5c654bbc6dddcd04157ecafb0" + "sha256": "747e3ff37ed559ecc83348a6bc08ecf79c54eae60dc405f6ee2977b7e91026be" }, "pipfile-spec": 6, "requires": { @@ -60,74 +60,88 @@ }, "certifi": { "hashes": [ - "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7", - "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a" + "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", + "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" ], "markers": "python_version >= '3.6'", - "version": "==2022.5.18.1" + "version": "==2022.6.15" }, "cffi": { "hashes": [ - "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", - "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", - "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", - "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", - "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", - "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", - "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", - "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", - "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", - "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", - "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", - "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", - "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", - "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", - "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", - "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", - "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", - "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", - "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", - "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", - "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", - "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", - "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", - "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", - "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", - "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", - "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", - "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", - "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", - "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", - "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", - "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", - "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", - "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", - "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", - "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", - "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", - "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", - "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", - "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", - "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", - "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", - "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", - "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", - "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", - "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", - "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", - "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", - "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", - "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" + "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", + "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", + "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", + "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", + "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", + "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", + "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", + "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", + "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", + "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", + "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", + "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", + "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", + "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", + "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", + "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", + "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", + "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", + "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", + "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", + "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", + "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", + "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", + "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", + "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", + "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", + "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", + "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", + "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", + "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", + "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", + "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", + "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", + "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", + "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", + "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", + "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", + "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", + "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", + "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", + "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", + "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", + "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", + "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", + "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", + "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", + "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", + "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", + "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", + "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", + "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", + "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", + "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", + "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", + "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", + "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", + "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", + "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", + "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", + "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", + "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", + "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", + "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", + "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" ], - "version": "==1.15.0" + "version": "==1.15.1" }, "charset-normalizer": { "hashes": [ - "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", - "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5", + "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413" ], - "markers": "python_version >= '3.5'", - "version": "==2.0.12" + "markers": "python_version >= '3.6'", + "version": "==2.1.0" }, "click": { "hashes": [ @@ -142,7 +156,7 @@ "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035" ], - "markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", + "markers": "python_version < '4.0' and python_full_version >= '3.6.2'", "version": "==0.3.0" }, "click-plugins": { @@ -161,30 +175,30 @@ }, "cryptography": { "hashes": [ - "sha256:093cb351031656d3ee2f4fa1be579a8c69c754cf874206be1d4cf3b542042804", - "sha256:0cc20f655157d4cfc7bada909dc5cc228211b075ba8407c46467f63597c78178", - "sha256:1b9362d34363f2c71b7853f6251219298124aa4cc2075ae2932e64c91a3e2717", - "sha256:1f3bfbd611db5cb58ca82f3deb35e83af34bb8cf06043fa61500157d50a70982", - "sha256:2bd1096476aaac820426239ab534b636c77d71af66c547b9ddcd76eb9c79e004", - "sha256:31fe38d14d2e5f787e0aecef831457da6cec68e0bb09a35835b0b44ae8b988fe", - "sha256:3b8398b3d0efc420e777c40c16764d6870bcef2eb383df9c6dbb9ffe12c64452", - "sha256:3c81599befb4d4f3d7648ed3217e00d21a9341a9a688ecdd615ff72ffbed7336", - "sha256:419c57d7b63f5ec38b1199a9521d77d7d1754eb97827bbb773162073ccd8c8d4", - "sha256:46f4c544f6557a2fefa7ac8ac7d1b17bf9b647bd20b16decc8fbcab7117fbc15", - "sha256:471e0d70201c069f74c837983189949aa0d24bb2d751b57e26e3761f2f782b8d", - "sha256:59b281eab51e1b6b6afa525af2bd93c16d49358404f814fe2c2410058623928c", - "sha256:731c8abd27693323b348518ed0e0705713a36d79fdbd969ad968fbef0979a7e0", - "sha256:95e590dd70642eb2079d280420a888190aa040ad20f19ec8c6e097e38aa29e06", - "sha256:a68254dd88021f24a68b613d8c51d5c5e74d735878b9e32cc0adf19d1f10aaf9", - "sha256:a7d5137e556cc0ea418dca6186deabe9129cee318618eb1ffecbd35bee55ddc1", - "sha256:aeaba7b5e756ea52c8861c133c596afe93dd716cbcacae23b80bc238202dc023", - "sha256:dc26bb134452081859aa21d4990474ddb7e863aa39e60d1592800a8865a702de", - "sha256:e53258e69874a306fcecb88b7534d61820db8a98655662a3dd2ec7f1afd9132f", - "sha256:ef15c2df7656763b4ff20a9bc4381d8352e6640cfeb95c2972c38ef508e75181", - "sha256:f224ad253cc9cea7568f49077007d2263efa57396a2f2f78114066fd54b5c68e", - "sha256:f8ec91983e638a9bcd75b39f1396e5c0dc2330cbd9ce4accefe68717e6779e0a" + "sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59", + "sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596", + "sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3", + "sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5", + "sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab", + "sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884", + "sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82", + "sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b", + "sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441", + "sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa", + "sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d", + "sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b", + "sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a", + "sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6", + "sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157", + "sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280", + "sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282", + "sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67", + "sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8", + "sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046", + "sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327", + "sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9" ], - "version": "==37.0.2" + "version": "==37.0.4" }, "defusedxml": { "hashes": [ @@ -254,11 +268,11 @@ }, "django-celery-results": { "hashes": [ - "sha256:b8c9416619dbcc38f13398e31bcb1f14a228cd1e8f65fb22d3b7fc68aaa5331a", - "sha256:bf24ecc29c42e49cc7eb30b9b3739471331e2a0ca517cc88ca53a0cf3a2031d1" + "sha256:75aa51970db5691cbf242c6a0ff50c8cdf419e265cd0e9b772335d06436c4b99", + "sha256:be91307c02fbbf0dda21993c3001c60edb74595444ccd6ad696552fe3689e85b" ], "index": "pypi", - "version": "==2.3.1" + "version": "==2.4.0" }, "django-compressor": { "hashes": [ @@ -270,11 +284,11 @@ }, "django-filter": { "hashes": [ - "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e", - "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063" + "sha256:ed429e34760127e3520a67f415bec4c905d4649fbe45d0d6da37e6ff5e0287eb", + "sha256:ed473b76e84f7e83b2511bb2050c3efb36d135207d0128dfe3ae4b36e3594ba5" ], "index": "pypi", - "version": "==21.1" + "version": "==22.1" }, "django-localflavor": { "hashes": [ @@ -343,7 +357,7 @@ "sha256:15746ed367a5a32eda76cfa2886eeec1de8cda79f519b7c5e12f87ed7cdbd663", "sha256:199f211082eeac7e83563929b8ce41399c1c0f00dfc2f36bc00bea381027eaaa" ], - "markers": "python_version >= '3.7' and python_full_version < '4.0.0'", + "markers": "python_version >= '3.7' and python_version < '4.0'", "version": "==5.0" }, "gunicorn": { @@ -372,72 +386,80 @@ }, "lxml": { "hashes": [ - "sha256:00f3a6f88fd5f4357844dd91a1abac5f466c6799f1b7f1da2df6665253845b11", - "sha256:024684e0c5cfa121c22140d3a0898a3a9b2ea0f0fd2c229b6658af4bdf1155e5", - "sha256:03370ec37fe562238d385e2c53089076dee53aabf8325cab964fdb04a9130fa0", - "sha256:0aa4cce579512c33373ca4c5e23c21e40c1aa1a33533a75e51b654834fd0e4f2", - "sha256:1057356b808d149bc14eb8f37bb89129f237df488661c1e0fc0376ca90e1d2c3", - "sha256:11d62c97ceff9bab94b6b29c010ea5fb6831743459bb759c917f49ba75601cd0", - "sha256:1254a79f8a67a3908de725caf59eae62d86738f6387b0a34b32e02abd6ae73db", - "sha256:1bfb791a8fcdbf55d1d41b8be940393687bec0e9b12733f0796668086d1a23ff", - "sha256:28cf04a1a38e961d4a764d2940af9b941b66263ed5584392ef875ee9c1e360a3", - "sha256:2b9c2341d96926b0d0e132e5c49ef85eb53fa92ae1c3a70f9072f3db0d32bc07", - "sha256:2d10659e6e5c53298e6d718fd126e793285bff904bb71d7239a17218f6a197b7", - "sha256:3af00ee88376022589ceeb8170eb67dacf5f7cd625ea59fa0977d719777d4ae8", - "sha256:3cf816aed8125cfc9e6e5c6c31ff94278320d591bd7970c4a0233bee0d1c8790", - "sha256:4becd16750ca5c2a1b1588269322b2cebd10c07738f336c922b658dbab96a61c", - "sha256:4cd69bca464e892ea4ed544ba6a7850aaff6f8d792f8055a10638db60acbac18", - "sha256:4e97c8fc761ad63909198acc892f34c20f37f3baa2c50a62d5ec5d7f1efc68a1", - "sha256:520461c36727268a989790aef08884347cd41f2d8ae855489ccf40b50321d8d7", - "sha256:53b0410b220766321759f7f9066da67b1d0d4a7f6636a477984cbb1d98483955", - "sha256:56e19fb6e4b8bd07fb20028d03d3bc67bcc0621347fbde64f248e44839771756", - "sha256:5a49ad78543925e1a4196e20c9c54492afa4f1502c2a563f73097e2044c75190", - "sha256:5d52e1173f52020392f593f87a6af2d4055dd800574a5cb0af4ea3878801d307", - "sha256:607224ffae9a0cf0a2f6e14f5f6bce43e83a6fbdaa647891729c103bdd6a5593", - "sha256:612ef8f2795a89ba3a1d4c8c1af84d8453fd53ee611aa5ad460fdd2cab426fc2", - "sha256:615886ee84b6f42f1bdf1852a9669b5fe3b96b6ff27f1a7a330b67ad9911200a", - "sha256:63419db39df8dc5564f6f103102c4665f7e4d9cb64030e98cf7a74eae5d5760d", - "sha256:6467626fa74f96f4d80fc6ec2555799e97fff8f36e0bfc7f67769f83e59cff40", - "sha256:65b3b5f12c6fb5611e79157214f3cd533083f9b058bf2fc8a1c5cc5ee40fdc5a", - "sha256:686565ac77ff94a8965c11829af253d9e2ce3bf0d9225b1d2eb5c4d4666d0dca", - "sha256:6af7f51a6010748fc1bb71917318d953c9673e4ae3f6d285aaf93ef5b2eb11c1", - "sha256:70a198030d26f5e569367f0f04509b63256faa76a22886280eea69a4f535dd40", - "sha256:754a1dd04bff8a509a31146bd8f3a5dc8191a8694d582dd5fb71ff09f0722c22", - "sha256:75da29a0752c8f2395df0115ac1681cefbdd4418676015be8178b733704cbff2", - "sha256:81c29c8741fa07ecec8ec7417c3d8d1e2f18cf5a10a280f4e1c3f8c3590228b2", - "sha256:9093a359a86650a3dbd6532c3e4d21a6f58ba2cb60d0e72db0848115d24c10ba", - "sha256:915ecf7d486df17cc65aeefdb680d5ad4390cc8c857cf8db3fe241ed234f856a", - "sha256:94b181dd2777890139e49a5336bf3a9a3378ce66132c665fe8db4e8b7683cde2", - "sha256:94f2e45b054dd759bed137b6e14ae8625495f7d90ddd23cf62c7a68f72b62656", - "sha256:9af19eb789d674b59a9bee5005779757aab857c40bf9cc313cb01eafac55ce55", - "sha256:9cae837b988f44925d14d048fa6a8c54f197c8b1223fd9ee9c27084f84606143", - "sha256:aa7447bf7c1a15ef24e2b86a277b585dd3f055e8890ac7f97374d170187daa97", - "sha256:b1e22f3ee4d75ca261b6bffbf64f6f178cb194b1be3191065a09f8d98828daa9", - "sha256:b5031d151d6147eac53366d6ec87da84cd4d8c5e80b1d9948a667a7164116e39", - "sha256:b62d1431b4c40cda43cc986f19b8c86b1d2ae8918cfc00f4776fdf070b65c0c4", - "sha256:b71c52d69b91af7d18c13aef1b0cc3baee36b78607c711eb14a52bf3aa7c815e", - "sha256:b7679344f2270840dc5babc9ccbedbc04f7473c1f66d4676bb01680c0db85bcc", - "sha256:bb7c1b029e54e26e01b1d1d912fc21abb65650d16ea9a191d026def4ed0859ed", - "sha256:c2a57755e366e0ac7ebdb3e9207f159c3bf1afed02392ab18453ce81f5ee92ee", - "sha256:cf9ec915857d260511399ab87e1e70fa13d6b2972258f8e620a3959468edfc32", - "sha256:d0d03b9636f1326772e6854459728676354d4c7731dae9902b180e2065ba3da6", - "sha256:d1690c4d37674a5f0cdafbc5ed7e360800afcf06928c2a024c779c046891bf09", - "sha256:d76da27f5e3e9bc40eba6ed7a9e985f57547e98cf20521d91215707f2fb57e0f", - "sha256:d882c2f3345261e898b9f604be76b61c901fbfa4ac32e3f51d5dc1edc89da3cb", - "sha256:d8e5021e770b0a3084c30dda5901d5fce6d4474feaf0ced8f8e5a82702502fbb", - "sha256:dd00d28d1ab5fa7627f5abc957f29a6338a7395b724571a8cbff8fbed83aaa82", - "sha256:e35a298691b9e10e5a5631f8f0ba605b30ebe19208dc8f58b670462f53753641", - "sha256:e4d020ecf3740b7312bacab2cb966bb720fd4d3490562d373b4ad91dd1857c0d", - "sha256:e564d5a771b4015f34166a05ea2165b7e283635c41b1347696117f780084b46d", - "sha256:ea3f2e9eb41f973f73619e88bf7bd950b16b4c2ce73d15f24a11800ce1eaf276", - "sha256:eabdbe04ee0a7e760fa6cd9e799d2b020d098c580ba99107d52e1e5e538b1ecb", - "sha256:f17b9df97c5ecdfb56c5e85b3c9df9831246df698f8581c6e111ac664c7c656e", - "sha256:f386def57742aacc3d864169dfce644a8c396f95aa35b41b69df53f558d56dd0", - "sha256:f6d23a01921b741774f35e924d418a43cf03eca1444f3fdfd7978d35a5aaab8b", - "sha256:fcdf70191f0d1761d190a436db06a46f05af60e1410e1507935f0332280c9268" + "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318", + "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c", + "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b", + "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000", + "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73", + "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d", + "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb", + "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8", + "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2", + "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345", + "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94", + "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e", + "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b", + "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc", + "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a", + "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9", + "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc", + "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387", + "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb", + "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7", + "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4", + "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97", + "sha256:49a866923e69bc7da45a0565636243707c22752fc38f6b9d5c8428a86121022c", + "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67", + "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627", + "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7", + "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd", + "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3", + "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7", + "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130", + "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b", + "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036", + "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785", + "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca", + "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91", + "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc", + "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536", + "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391", + "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3", + "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d", + "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21", + "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3", + "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d", + "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29", + "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715", + "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed", + "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25", + "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c", + "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785", + "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837", + "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4", + "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b", + "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2", + "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067", + "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448", + "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d", + "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2", + "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc", + "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c", + "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5", + "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84", + "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8", + "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf", + "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7", + "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e", + "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb", + "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b", + "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3", + "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad", + "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8", + "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.9.0" + "version": "==4.9.1" }, "measurement": { "hashes": [ @@ -486,55 +508,75 @@ }, "pillow": { "hashes": [ - "sha256:088df396b047477dd1bbc7de6e22f58400dae2f21310d9e2ec2933b2ef7dfa4f", - "sha256:09e67ef6e430f90caa093528bd758b0616f8165e57ed8d8ce014ae32df6a831d", - "sha256:0b4d5ad2cd3a1f0d1df882d926b37dbb2ab6c823ae21d041b46910c8f8cd844b", - "sha256:0b525a356680022b0af53385944026d3486fc8c013638cf9900eb87c866afb4c", - "sha256:1d4331aeb12f6b3791911a6da82de72257a99ad99726ed6b63f481c0184b6fb9", - "sha256:20d514c989fa28e73a5adbddd7a171afa5824710d0ab06d4e1234195d2a2e546", - "sha256:2b291cab8a888658d72b575a03e340509b6b050b62db1f5539dd5cd18fd50578", - "sha256:3f6c1716c473ebd1649663bf3b42702d0d53e27af8b64642be0dd3598c761fb1", - "sha256:42dfefbef90eb67c10c45a73a9bc1599d4dac920f7dfcbf4ec6b80cb620757fe", - "sha256:488f3383cf5159907d48d32957ac6f9ea85ccdcc296c14eca1a4e396ecc32098", - "sha256:4d45dbe4b21a9679c3e8b3f7f4f42a45a7d3ddff8a4a16109dff0e1da30a35b2", - "sha256:53c27bd452e0f1bc4bfed07ceb235663a1df7c74df08e37fd6b03eb89454946a", - "sha256:55e74faf8359ddda43fee01bffbc5bd99d96ea508d8a08c527099e84eb708f45", - "sha256:59789a7d06c742e9d13b883d5e3569188c16acb02eeed2510fd3bfdbc1bd1530", - "sha256:5b650dbbc0969a4e226d98a0b440c2f07a850896aed9266b6fedc0f7e7834108", - "sha256:66daa16952d5bf0c9d5389c5e9df562922a59bd16d77e2a276e575d32e38afd1", - "sha256:6e760cf01259a1c0a50f3c845f9cad1af30577fd8b670339b1659c6d0e7a41dd", - "sha256:7502539939b53d7565f3d11d87c78e7ec900d3c72945d4ee0e2f250d598309a0", - "sha256:769a7f131a2f43752455cc72f9f7a093c3ff3856bf976c5fb53a59d0ccc704f6", - "sha256:7c150dbbb4a94ea4825d1e5f2c5501af7141ea95825fadd7829f9b11c97aaf6c", - "sha256:8844217cdf66eabe39567118f229e275f0727e9195635a15e0e4b9227458daaf", - "sha256:8a66fe50386162df2da701b3722781cbe90ce043e7d53c1fd6bd801bca6b48d4", - "sha256:9370d6744d379f2de5d7fa95cdbd3a4d92f0b0ef29609b4b1687f16bc197063d", - "sha256:937a54e5694684f74dcbf6e24cc453bfc5b33940216ddd8f4cd8f0f79167f765", - "sha256:9c857532c719fb30fafabd2371ce9b7031812ff3889d75273827633bca0c4602", - "sha256:a4165205a13b16a29e1ac57efeee6be2dfd5b5408122d59ef2145bc3239fa340", - "sha256:b3fe2ff1e1715d4475d7e2c3e8dabd7c025f4410f79513b4ff2de3d51ce0fa9c", - "sha256:b6617221ff08fbd3b7a811950b5c3f9367f6e941b86259843eab77c8e3d2b56b", - "sha256:b761727ed7d593e49671d1827044b942dd2f4caae6e51bab144d4accf8244a84", - "sha256:baf3be0b9446a4083cc0c5bb9f9c964034be5374b5bc09757be89f5d2fa247b8", - "sha256:c17770a62a71718a74b7548098a74cd6880be16bcfff5f937f900ead90ca8e92", - "sha256:c67db410508b9de9c4694c57ed754b65a460e4812126e87f5052ecf23a011a54", - "sha256:d78ca526a559fb84faaaf84da2dd4addef5edb109db8b81677c0bb1aad342601", - "sha256:e9ed59d1b6ee837f4515b9584f3d26cf0388b742a11ecdae0d9237a94505d03a", - "sha256:f054b020c4d7e9786ae0404278ea318768eb123403b18453e28e47cdb7a0a4bf", - "sha256:f372d0f08eff1475ef426344efe42493f71f377ec52237bf153c5713de987251", - "sha256:f3f6a6034140e9e17e9abc175fc7a266a6e63652028e157750bd98e804a8ed9a", - "sha256:ffde4c6fabb52891d81606411cbfaf77756e3b561b566efd270b3ed3791fde4e" + "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927", + "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14", + "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc", + "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58", + "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60", + "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76", + "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c", + "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac", + "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490", + "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1", + "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f", + "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d", + "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f", + "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069", + "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402", + "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885", + "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e", + "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be", + "sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8", + "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff", + "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da", + "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004", + "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f", + "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20", + "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d", + "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c", + "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544", + "sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9", + "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3", + "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04", + "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c", + "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5", + "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4", + "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb", + "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4", + "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c", + "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467", + "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e", + "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421", + "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b", + "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8", + "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb", + "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3", + "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf", + "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1", + "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a", + "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28", + "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0", + "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1", + "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8", + "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd", + "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4", + "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8", + "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f", + "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013", + "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59", + "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc", + "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4" ], "index": "pypi", - "version": "==9.1.1" + "version": "==9.2.0" }, "prompt-toolkit": { "hashes": [ - "sha256:62291dad495e665fca0bda814e342c69952086afb0f4094d0893d357e5c78752", - "sha256:bd640f60e8cecd74f0dc249713d433ace2ddc62b65ee07f96d358e0b152b6ea7" + "sha256:859b283c50bde45f5f97829f77a4674d1c1fcd88539364f1b28a37805cfd89c0", + "sha256:d8916d3f62a7b67ab353a952ce4ced6a1d2587dfe9ef8ebc30dd7c386751f289" ], "markers": "python_full_version >= '3.6.2'", - "version": "==3.0.29" + "version": "==3.0.30" }, "psycopg2-binary": { "hashes": [ @@ -741,19 +783,19 @@ }, "redis": { "hashes": [ - "sha256:2f7a57cf4af15cd543c4394bcbe2b9148db2606a37edba755368836e3a1d053e", - "sha256:f57f8df5d238a8ecf92f499b6b21467bfee6c13d89953c27edf1e2bc673622e7" + "sha256:a52d5694c9eb4292770084fa8c863f79367ca19884b329ab574d5cb2036b3e54", + "sha256:ddf27071df4adf3821c4f2ca59d67525c3a82e5f268bed97b813cb4fabf87880" ], "index": "pypi", - "version": "==4.3.3" + "version": "==4.3.4" }, "requests": { "hashes": [ - "sha256:bc7861137fbce630f17b03d3ad02ad0bf978c844f3536d0edda6499dafce2b6f", - "sha256:d568723a7ebd25875d8d1eaf5dfa068cd2fc8194b2e483d7b1f7c81918dbec6b" + "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", + "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" ], - "markers": "python_version >= '3.7' and python_full_version < '4.0.0'", - "version": "==2.28.0" + "markers": "python_version >= '3.7' and python_version < '4.0'", + "version": "==2.28.1" }, "requests-oauthlib": { "hashes": [ @@ -790,19 +832,19 @@ }, "sentry-sdk": { "hashes": [ - "sha256:259535ba66933eacf85ab46524188c84dcb4c39f40348455ce15e2c0aca68863", - "sha256:778b53f0a6c83b1ee43d3b7886318ba86d975e686cb2c7906ccc35b334360be1" + "sha256:6f460da98b730d671510af18f119f96d01e3ba027ac0e985871abb3aede1c514", + "sha256:95fd321f583dfcfaf279a0b2cdc83d8d28c8b7cca4d2959fc4539bb4fecb56a0" ], "index": "pypi", - "version": "==1.5.12" + "version": "==1.7.2" }, "setuptools": { "hashes": [ - "sha256:d1746e7fd520e83bbe210d02fff1aa1a425ad671c7a9da7d246ec2401a087198", - "sha256:e7d11f3db616cda0751372244c2ba798e8e56a28e096ec4529010b803485f3fe" + "sha256:0d33c374d41c7863419fc8f6c10bfe25b7b498aa34164d135c622e52580c6b16", + "sha256:c04b44a57a6265fe34a4a444e965884716d34bae963119a76353434d6f18e450" ], "markers": "python_version >= '3.7'", - "version": "==62.3.3" + "version": "==63.2.0" }, "six": { "hashes": [ @@ -820,6 +862,14 @@ "markers": "python_version >= '3.5'", "version": "==0.4.2" }, + "stripe": { + "hashes": [ + "sha256:08f74cae6619d4a7d78f8162ff72bc3e9c913f53ec96ecd5ddc7d823c2e79ddd", + "sha256:69d5bf4611624a503bcec84a61b1f2a2b874bfc828432e4fd75cd120bcc3efef" + ], + "index": "pypi", + "version": "==3.5.0" + }, "sympy": { "hashes": [ "sha256:5939eeffdf9e152172601463626c022a2c27e75cf6278de8d401d50c9d58787b", @@ -838,11 +888,11 @@ }, "urllib3": { "hashes": [ - "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", - "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", + "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_full_version < '4.0.0'", - "version": "==1.26.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4.0'", + "version": "==1.26.10" }, "usps-api": { "hashes": [ @@ -972,93 +1022,107 @@ }, "certifi": { "hashes": [ - "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7", - "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a" + "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", + "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" ], "markers": "python_version >= '3.6'", - "version": "==2022.5.18.1" + "version": "==2022.6.15" }, "cffi": { "hashes": [ - "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", - "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", - "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", - "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", - "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", - "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", - "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", - "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", - "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", - "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", - "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", - "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", - "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", - "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", - "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", - "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", - "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", - "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", - "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", - "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", - "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", - "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", - "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", - "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", - "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", - "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", - "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", - "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", - "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", - "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", - "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", - "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", - "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", - "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", - "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", - "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", - "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", - "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", - "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", - "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", - "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", - "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", - "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", - "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", - "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", - "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", - "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", - "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", - "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", - "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" + "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", + "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", + "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", + "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", + "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", + "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", + "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", + "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", + "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", + "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", + "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", + "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", + "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", + "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", + "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", + "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", + "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", + "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", + "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", + "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", + "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", + "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", + "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", + "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", + "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", + "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", + "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", + "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", + "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", + "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", + "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", + "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", + "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", + "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", + "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", + "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", + "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", + "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", + "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", + "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", + "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", + "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", + "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", + "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", + "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", + "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", + "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", + "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", + "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", + "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", + "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", + "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", + "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", + "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", + "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", + "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", + "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", + "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", + "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", + "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", + "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", + "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", + "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", + "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" ], - "version": "==1.15.0" + "version": "==1.15.1" }, "cryptography": { "hashes": [ - "sha256:093cb351031656d3ee2f4fa1be579a8c69c754cf874206be1d4cf3b542042804", - "sha256:0cc20f655157d4cfc7bada909dc5cc228211b075ba8407c46467f63597c78178", - "sha256:1b9362d34363f2c71b7853f6251219298124aa4cc2075ae2932e64c91a3e2717", - "sha256:1f3bfbd611db5cb58ca82f3deb35e83af34bb8cf06043fa61500157d50a70982", - "sha256:2bd1096476aaac820426239ab534b636c77d71af66c547b9ddcd76eb9c79e004", - "sha256:31fe38d14d2e5f787e0aecef831457da6cec68e0bb09a35835b0b44ae8b988fe", - "sha256:3b8398b3d0efc420e777c40c16764d6870bcef2eb383df9c6dbb9ffe12c64452", - "sha256:3c81599befb4d4f3d7648ed3217e00d21a9341a9a688ecdd615ff72ffbed7336", - "sha256:419c57d7b63f5ec38b1199a9521d77d7d1754eb97827bbb773162073ccd8c8d4", - "sha256:46f4c544f6557a2fefa7ac8ac7d1b17bf9b647bd20b16decc8fbcab7117fbc15", - "sha256:471e0d70201c069f74c837983189949aa0d24bb2d751b57e26e3761f2f782b8d", - "sha256:59b281eab51e1b6b6afa525af2bd93c16d49358404f814fe2c2410058623928c", - "sha256:731c8abd27693323b348518ed0e0705713a36d79fdbd969ad968fbef0979a7e0", - "sha256:95e590dd70642eb2079d280420a888190aa040ad20f19ec8c6e097e38aa29e06", - "sha256:a68254dd88021f24a68b613d8c51d5c5e74d735878b9e32cc0adf19d1f10aaf9", - "sha256:a7d5137e556cc0ea418dca6186deabe9129cee318618eb1ffecbd35bee55ddc1", - "sha256:aeaba7b5e756ea52c8861c133c596afe93dd716cbcacae23b80bc238202dc023", - "sha256:dc26bb134452081859aa21d4990474ddb7e863aa39e60d1592800a8865a702de", - "sha256:e53258e69874a306fcecb88b7534d61820db8a98655662a3dd2ec7f1afd9132f", - "sha256:ef15c2df7656763b4ff20a9bc4381d8352e6640cfeb95c2972c38ef508e75181", - "sha256:f224ad253cc9cea7568f49077007d2263efa57396a2f2f78114066fd54b5c68e", - "sha256:f8ec91983e638a9bcd75b39f1396e5c0dc2330cbd9ce4accefe68717e6779e0a" + "sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59", + "sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596", + "sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3", + "sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5", + "sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab", + "sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884", + "sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82", + "sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b", + "sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441", + "sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa", + "sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d", + "sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b", + "sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a", + "sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6", + "sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157", + "sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280", + "sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282", + "sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67", + "sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8", + "sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046", + "sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327", + "sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9" ], - "version": "==37.0.2" + "version": "==37.0.4" }, "django": { "hashes": [ @@ -1070,11 +1134,11 @@ }, "django-debug-toolbar": { "hashes": [ - "sha256:42c1c2e9dc05bb57b53d641e3a6d131fc031b92377b34ae32e604a1fe439bb83", - "sha256:ae6bec2c1ce0e6900b0ab0443e1427eb233d8e6f57a84a0b2705eeecb8874e22" + "sha256:89a52128309eb4da12738801ff0c202d2ff8730d1c3225fac6acf630c303e661", + "sha256:97965f2630692de316ea0c1ca5bfa81660d7ba13146dbc6be2059cf55b35d0e5" ], "index": "pypi", - "version": "==3.4.0" + "version": "==3.5.0" }, "h11": { "hashes": [ @@ -1094,11 +1158,11 @@ }, "outcome": { "hashes": [ - "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958", - "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967" + "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672", + "sha256:c4ab89a56575d6d38a05aa16daeaa333109c1f96167aba8901ab18b6b5e0f7f5" ], - "markers": "python_version >= '3.6'", - "version": "==1.1.0" + "markers": "python_version >= '3.7'", + "version": "==1.2.0" }, "pycodestyle": { "hashes": [ @@ -1133,10 +1197,10 @@ }, "selenium": { "hashes": [ - "sha256:ba5b2633f43cf6fe9d308fa4a6996e00a101ab9cb1aad6fd91ae1f3dbe57f56f" + "sha256:f67402b8f973aaa98d9c55b8f9aa63532009cd1859b2222a8b9800354942d8bc" ], "index": "pypi", - "version": "==4.2.0" + "version": "==4.3.0" }, "sniffio": { "hashes": [ @@ -1166,7 +1230,7 @@ "sha256:4dc0bf9d5cc78767fc4516325b6d80cc0968705a31d0eec2ecd7cdda466265b0", "sha256:523f39b7b69eef73501cebfe1aafd400a9aad5b03543a0eded52952488ff1c13" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==0.21.0" }, "trio-websocket": { @@ -1179,18 +1243,18 @@ }, "urllib3": { "hashes": [ - "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", - "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", + "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_full_version < '4.0.0'", - "version": "==1.26.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4.0'", + "version": "==1.26.10" }, "wsproto": { "hashes": [ "sha256:2218cb57952d90b9fca325c0dcfb08c3bda93e8fd8070b0a17f048e2e47a521b", "sha256:a2e56bfd5c7cd83c1369d83b5feccd6d37798b74872866e62616e0ecf111bda8" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==1.1.0" } } diff --git a/src/accounts/apps.py b/src/accounts/apps.py index 3e3c765..35cfd1f 100644 --- a/src/accounts/apps.py +++ b/src/accounts/apps.py @@ -4,3 +4,8 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'accounts' + + # def ready(self): + # from .signals import ( + # user_saved + # ) diff --git a/src/accounts/migrations/0002_alter_address_state.py b/src/accounts/migrations/0002_alter_address_state.py index b89118b..f4baf5c 100644 --- a/src/accounts/migrations/0002_alter_address_state.py +++ b/src/accounts/migrations/0002_alter_address_state.py @@ -1,4 +1,4 @@ -# Generated by Django 4.0.2 on 2022-07-09 14:14 +# Generated by Django 4.0.2 on 2022-07-23 17:28 from django.db import migrations, models diff --git a/src/accounts/migrations/0003_user_stripe_id.py b/src/accounts/migrations/0003_user_stripe_id.py new file mode 100644 index 0000000..728ba24 --- /dev/null +++ b/src/accounts/migrations/0003_user_stripe_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2022-07-30 23:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_address_state'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='stripe_id', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/src/accounts/models.py b/src/accounts/models.py index ed56e74..bdda95d 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -18,7 +18,12 @@ class Address(models.Model): postal_code = models.CharField(max_length=20, blank=True) def __str__(self): - return f'{self.street_address_1} — {self.city}' + return f""" + {self.first_name} {self.last_name} + {self.street_address_1} + {self.street_address_2} + {self.city}, {self.state}, {self.postal_code} + """ class User(AbstractUser): @@ -31,3 +36,4 @@ class User(AbstractUser): default_billing_address = models.ForeignKey( Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL ) + stripe_id = models.CharField(max_length=255, blank=True) diff --git a/src/accounts/signals.py b/src/accounts/signals.py new file mode 100644 index 0000000..97ac32e --- /dev/null +++ b/src/accounts/signals.py @@ -0,0 +1,22 @@ +import logging +import stripe +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.db import models +from django.conf import settings + +from .models import Address, User + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=User, dispatch_uid='user_saved') +def user_saved(sender, instance, created, **kwargs): + logger.info('User was saved') + if created or not instance.stripe_id: + stripe.api_key = settings.STRIPE_API_KEY + response = stripe.Customer.create( + name=instance.first_name + instance.last_name + ) + instance.stripe_id = response['id'] + instance.save() diff --git a/src/accounts/utils.py b/src/accounts/utils.py index 11d4cf4..f1b24a9 100644 --- a/src/accounts/utils.py +++ b/src/accounts/utils.py @@ -23,7 +23,7 @@ def get_or_create_customer(request, form, shipping_address): user.save() else: user, u_created = User.objects.get_or_create( - email=form.cleaned_data['email'], + email=form.cleaned_data['email'].lower(), defaults={ 'username': form.cleaned_data['email'].lower(), 'is_staff': False, diff --git a/src/core/__init__.py b/src/core/__init__.py index 16798fe..f8e65f7 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -66,13 +66,15 @@ class TransactionStatus: ] -class ShippingMethodType: - PRICE_BASED = 'price' - WEIGHT_BASED = 'weight' +class ShippingProvider: + USPS = 'USPS' + # UPS = 'UPS' + # FEDEX = 'FEDEX' CHOICES = [ - (PRICE_BASED, 'Price based shipping'), - (WEIGHT_BASED, 'Weight based shipping'), + (USPS, 'USPS'), + # (UPS, 'UPS'), + # (FEDEX, 'FedEx'), ] @@ -125,3 +127,20 @@ class CoffeeGrind: (PERCOLATOR, 'Percolator'), (CAFE_STYLE, 'BLTC cafe pour over') ] + + +def build_usps_rate_request(weight, container, zip_destination): + return \ + { + 'service': ShippingService.PRIORITY_COMMERCIAL, + 'zip_origination': settings.DEFAULT_ZIP_ORIGINATION, + 'zip_destination': zip_destination, + 'pounds': '0', + 'ounces': weight, + 'container': container, + 'width': '', + 'length': '', + 'height': '', + 'girth': '', + 'machinable': 'TRUE' + } diff --git a/src/core/admin.py b/src/core/admin.py index 62c9ccc..324c630 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -1,19 +1,27 @@ from django.contrib import admin from .models import ( + SiteSettings, + ProductCategory, Product, ProductPhoto, + ProductVariant, + ProductOption, Coupon, - ShippingMethod, + ShippingRate, Order, Transaction, OrderLine, ) +admin.site.register(SiteSettings) +admin.site.register(ProductCategory) admin.site.register(Product) admin.site.register(ProductPhoto) +admin.site.register(ProductVariant) +admin.site.register(ProductOption) admin.site.register(Coupon) -admin.site.register(ShippingMethod) +admin.site.register(ShippingRate) admin.site.register(Order) admin.site.register(Transaction) admin.site.register(OrderLine) diff --git a/src/core/apps.py b/src/core/apps.py index cf4aa6c..6cb4229 100644 --- a/src/core/apps.py +++ b/src/core/apps.py @@ -7,6 +7,7 @@ class CoreConfig(AppConfig): def ready(self): from .signals import ( + # variant_saved, order_created, transaction_created, order_line_post_save, diff --git a/src/core/context_processors.py b/src/core/context_processors.py new file mode 100644 index 0000000..eb6f1c8 --- /dev/null +++ b/src/core/context_processors.py @@ -0,0 +1,5 @@ +from .models import SiteSettings + + +def site_settings(request): + return {'site_settings': SiteSettings.load()} diff --git a/src/core/fixtures/orders.json b/src/core/fixtures/orders.json index f0c17c2..5b40378 100644 --- a/src/core/fixtures/orders.json +++ b/src/core/fixtures/orders.json @@ -10,7 +10,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "subtotal_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:18:59.584Z", "updated_at": "2022-03-15T17:18:59.584Z" @@ -26,7 +26,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "subtotal_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:22:18.440Z", "updated_at": "2022-03-15T17:22:18.440Z" @@ -42,7 +42,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "subtotal_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:26:27.869Z", "updated_at": "2022-03-15T17:26:27.869Z" diff --git a/src/core/forms.py b/src/core/forms.py index 50990a6..46b1d1f 100644 --- a/src/core/forms.py +++ b/src/core/forms.py @@ -2,11 +2,11 @@ import logging from django import forms from django.core.mail import EmailMessage -from core.models import Order, OrderLine, ShippingMethod +from core.models import Order, OrderLine, ShippingRate logger = logging.getLogger(__name__) -class ShippingMethodForm(forms.ModelForm): +class ShippingRateForm(forms.ModelForm): class Meta: - model = ShippingMethod + model = ShippingRate fields = '__all__' diff --git a/src/core/migrations/0001_initial.py b/src/core/migrations/0001_initial.py index f56f516..3774cc1 100644 --- a/src/core/migrations/0001_initial.py +++ b/src/core/migrations/0001_initial.py @@ -1,8 +1,9 @@ -# Generated by Django 4.0.2 on 2022-03-11 02:25 +# Generated by Django 4.0.2 on 2022-10-16 02:36 import core.weight from decimal import Decimal from django.conf import settings +import django.contrib.postgres.fields import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -16,46 +17,101 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('accounts', '0001_initial'), + ('accounts', '0003_user_stripe_id'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('entire_order', 'Entire order'), ('shipping', 'Shipping'), ('specific_product', 'Specific products, collections and categories')], default='entire_order', max_length=20)), + ('name', models.CharField(blank=True, max_length=255, null=True)), + ('code', models.CharField(db_index=True, max_length=12, unique=True)), + ('valid_from', models.DateTimeField(default=django.utils.timezone.now)), + ('valid_to', models.DateTimeField(blank=True, null=True)), + ('discount_value_type', models.CharField(choices=[('fixed', 'USD'), ('percentage', '%')], default='fixed', max_length=10)), + ('discount_value', models.DecimalField(decimal_places=2, max_digits=12)), + ], + options={ + 'ordering': ('code',), + }, + ), migrations.CreateModel( name='Order', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('draft', 'Draft'), ('unfulfilled', 'Unfulfilled'), ('partially fulfilled', 'Partially fulfilled'), ('partially_returned', 'Partially returned'), ('returned', 'Returned'), ('fulfilled', 'Fulfilled'), ('canceled', 'Canceled')], default='unfulfilled', max_length=32)), - ('total_net_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), - ('weight', django_measurement.models.MeasurementField(default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('unfulfilled', 'Unfulfilled'), ('partially_fulfilled', 'Partially fulfilled'), ('partially_returned', 'Partially returned'), ('returned', 'Returned'), ('fulfilled', 'Fulfilled'), ('canceled', 'Canceled')], default='unfulfilled', max_length=32)), + ('subtotal_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('coupon_amount', models.CharField(blank=True, max_length=255)), + ('shipping_total', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('billing_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')), + ('coupon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.coupon')), ('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to=settings.AUTH_USER_MODEL)), ('shipping_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')), ], + options={ + 'ordering': ('-created_at',), + }, ), migrations.CreateModel( name='Product', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=250)), + ('subtitle', models.CharField(blank=True, max_length=250)), ('description', models.TextField(blank=True)), - ('sku', models.CharField(max_length=255, unique=True)), - ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), - ('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)), + ('checkout_limit', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), ('visible_in_listings', models.BooleanField(default=False)), + ('sorting', models.PositiveIntegerField(blank=True, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ], + options={ + 'ordering': ['sorting', 'name'], + }, ), migrations.CreateModel( - name='ShippingMethod', + name='ProductCategory', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('type', models.CharField(choices=[('price', 'Price based shipping'), ('weight', 'Weight based shipping')], max_length=30)), + ('name', models.CharField(max_length=255)), + ('main_category', models.BooleanField(default=True)), ], + options={ + 'verbose_name': 'Product Category', + 'verbose_name_plural': 'Product Categories', + }, + ), + migrations.CreateModel( + name='ShippingRate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shipping_provider', models.CharField(choices=[('USPS', 'USPS')], default='USPS', max_length=255)), + ('name', models.CharField(max_length=255)), + ('container', models.CharField(choices=[('LG FLAT RATE BOX', 'Flate Rate Box - Large'), ('MD FLAT RATE BOX', 'Flate Rate Box - Medium'), ('REGIONALRATEBOXA', 'Regional Rate Box A'), ('REGIONALRATEBOXB', 'Regional Rate Box B'), ('VARIABLE', 'Variable')], default='VARIABLE', max_length=255)), + ('min_order_weight', models.PositiveIntegerField(blank=True, null=True)), + ('max_order_weight', models.PositiveIntegerField(blank=True, null=True)), + ], + options={ + 'ordering': ['min_order_weight'], + }, + ), + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('usps_user_id', models.CharField(max_length=255)), + ], + options={ + 'verbose_name': 'Site Settings', + 'verbose_name_plural': 'Site Settings', + }, ), migrations.CreateModel( name='Transaction', @@ -67,6 +123,47 @@ class Migration(migrations.Migration): ('order', models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.order')), ], ), + migrations.CreateModel( + name='TrackingNumber', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tracking_id', models.CharField(max_length=256)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('order', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='tracking_numbers', to='core.order')), + ], + options={ + 'verbose_name': 'Tracking Number', + 'verbose_name_plural': 'Tracking Numbers', + }, + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_id', models.CharField(blank=True, max_length=255)), + ('customer', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscription', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ProductVariant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('sku', models.CharField(max_length=255, unique=True)), + ('stripe_id', models.CharField(blank=True, max_length=255)), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)), + ('track_inventory', models.BooleanField(default=False)), + ('stock', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='core.product')), + ], + options={ + 'ordering': ['weight'], + }, + ), migrations.CreateModel( name='ProductPhoto', fields=[ @@ -75,6 +172,20 @@ class Migration(migrations.Migration): ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')), ], ), + migrations.CreateModel( + name='ProductOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('options', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), size=None)), + ('products', models.ManyToManyField(related_name='options', to='core.Product')), + ], + ), + migrations.AddField( + model_name='product', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.productcategory'), + ), migrations.CreateModel( name='OrderLine', fields=[ @@ -86,29 +197,17 @@ class Migration(migrations.Migration): ('unit_price', models.DecimalField(decimal_places=2, max_digits=12)), ('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.0'), max_digits=5)), ('order', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='core.order')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.product')), + ('variant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.productvariant')), ], ), migrations.AddField( - model_name='order', - name='shipping_method', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.shippingmethod'), + model_name='coupon', + name='products', + field=models.ManyToManyField(blank=True, to='core.Product'), ), - migrations.CreateModel( - name='Coupon', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.CharField(choices=[('entire_order', 'Entire order'), ('shipping', 'Shipping'), ('specific_product', 'Specific products, collections and categories')], default='entire_order', max_length=20)), - ('name', models.CharField(blank=True, max_length=255, null=True)), - ('code', models.CharField(db_index=True, max_length=12, unique=True)), - ('valid_from', models.DateTimeField(default=django.utils.timezone.now)), - ('valid_to', models.DateTimeField(blank=True, null=True)), - ('discount_value_type', models.CharField(choices=[('fixed', 'USD'), ('percentage', '%')], default='fixed', max_length=10)), - ('discount_value', models.DecimalField(decimal_places=2, max_digits=12)), - ('products', models.ManyToManyField(blank=True, to='core.Product')), - ], - options={ - 'ordering': ('code',), - }, + migrations.AddField( + model_name='coupon', + name='users', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), ), ] diff --git a/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py b/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py deleted file mode 100644 index 96dd6bd..0000000 --- a/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 4.0.2 on 2022-03-23 16:25 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='shippingmethod', - name='price', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12), - ), - migrations.AlterField( - model_name='order', - name='status', - field=models.CharField(choices=[('draft', 'Draft'), ('unfulfilled', 'Unfulfilled'), ('partially_fulfilled', 'Partially fulfilled'), ('partially_returned', 'Partially returned'), ('returned', 'Returned'), ('fulfilled', 'Fulfilled'), ('canceled', 'Canceled')], default='unfulfilled', max_length=32), - ), - migrations.CreateModel( - name='TrackingNumber', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('tracking_id', models.CharField(max_length=256)), - ('order', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='tracking_numbers', to='core.order')), - ], - options={ - 'verbose_name': 'Tracking Number', - 'verbose_name_plural': 'Tracking Numbers', - }, - ), - ] diff --git a/src/core/migrations/0002_shippingrate_is_selectable_and_more.py b/src/core/migrations/0002_shippingrate_is_selectable_and_more.py new file mode 100644 index 0000000..130ee1b --- /dev/null +++ b/src/core/migrations/0002_shippingrate_is_selectable_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.2 on 2022-10-29 14:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='shippingrate', + name='is_selectable', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='sitesettings', + name='default_shipping_rate', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.shippingrate'), + ), + ] diff --git a/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py b/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py deleted file mode 100644 index 170c781..0000000 --- a/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.0.2 on 2022-03-23 17:04 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0002_shippingmethod_price_alter_order_status_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='trackingnumber', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='trackingnumber', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/src/core/migrations/0004_order_coupon.py b/src/core/migrations/0004_order_coupon.py deleted file mode 100644 index f33dc45..0000000 --- a/src/core/migrations/0004_order_coupon.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.0.2 on 2022-03-23 21:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0003_trackingnumber_created_at_trackingnumber_updated_at'), - ] - - operations = [ - migrations.AddField( - model_name='order', - name='coupon', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.coupon'), - ), - ] diff --git a/src/core/migrations/0005_alter_product_options_product_sorting.py b/src/core/migrations/0005_alter_product_options_product_sorting.py deleted file mode 100644 index a7eca60..0000000 --- a/src/core/migrations/0005_alter_product_options_product_sorting.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.0.2 on 2022-03-28 17:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0004_order_coupon'), - ] - - operations = [ - migrations.AlterModelOptions( - name='product', - options={'ordering': ['sorting', 'name']}, - ), - migrations.AddField( - model_name='product', - name='sorting', - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/src/core/migrations/0006_alter_order_options_order_shipping_total.py b/src/core/migrations/0006_alter_order_options_order_shipping_total.py deleted file mode 100644 index 1af1363..0000000 --- a/src/core/migrations/0006_alter_order_options_order_shipping_total.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.0.2 on 2022-04-24 16:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0005_alter_product_options_product_sorting'), - ] - - operations = [ - migrations.AlterModelOptions( - name='order', - options={'ordering': ('-created_at',)}, - ), - migrations.AddField( - model_name='order', - name='shipping_total', - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - ] diff --git a/src/core/migrations/0007_product_subtitle.py b/src/core/migrations/0007_product_subtitle.py deleted file mode 100644 index 2d98dce..0000000 --- a/src/core/migrations/0007_product_subtitle.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.0.2 on 2022-04-30 15:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0006_alter_order_options_order_shipping_total'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='subtitle', - field=models.CharField(blank=True, max_length=250), - ), - ] diff --git a/src/core/migrations/0008_alter_order_coupon_alter_order_weight.py b/src/core/migrations/0008_alter_order_coupon_alter_order_weight.py deleted file mode 100644 index 035f81e..0000000 --- a/src/core/migrations/0008_alter_order_coupon_alter_order_weight.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 4.0.2 on 2022-04-30 23:11 - -import core.weight -from django.db import migrations, models -import django.db.models.deletion -import django_measurement.models -import measurement.measures.mass - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0007_product_subtitle'), - ] - - operations = [ - migrations.AlterField( - model_name='order', - name='coupon', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.coupon'), - ), - migrations.AlterField( - model_name='order', - name='weight', - field=django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True), - ), - ] diff --git a/src/core/migrations/0009_coupon_users.py b/src/core/migrations/0009_coupon_users.py deleted file mode 100644 index cb3293d..0000000 --- a/src/core/migrations/0009_coupon_users.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.0.2 on 2022-05-11 00:55 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0008_alter_order_coupon_alter_order_weight'), - ] - - operations = [ - migrations.AddField( - model_name='coupon', - name='users', - field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/src/core/models.py b/src/core/models.py index 59c8e2f..3e290e7 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -8,8 +8,10 @@ from django.db.models.functions import Coalesce from django.conf import settings from django.utils import timezone from django.urls import reverse +from django.core.cache import cache from django.core.validators import MinValueValidator, MaxValueValidator from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.fields import ArrayField, HStoreField from django.forms.models import model_to_dict from django_measurement.models import MeasurementField @@ -21,42 +23,67 @@ from . import ( VoucherType, TransactionStatus, OrderStatus, - ShippingMethodType + ShippingProvider, + ShippingContainer, + build_usps_rate_request ) from .weight import WeightUnits, zero_weight logger = logging.getLogger(__name__) -class ProductEncoder(DjangoJSONEncoder): - def default(self, obj): - logger.info(f"\n{obj}\n") - return super().default(obj) +class SingletonBase(models.Model): + def set_cache(self): + cache.set(self.__class__.__name__, self) + + def save(self, *args, **kwargs): + self.pk = 1 + super(SingletonBase, self).save(*args, **kwargs) + self.set_cache() + + def delete(self, *args, **kwargs): + pass + + @classmethod + def load(cls): + if cache.get(cls.__name__) is None: + obj, created = cls.objects.get_or_create(pk=1) + if not created: + obj.set_cache() + return cache.get(cls.__name__) + + class Meta: + abstract = True -class ProductManager(models.Manager): - def get_queryset(self): - return super().get_queryset().annotate( - num_ordered=models.Sum('order_lines__quantity') - ) +class ProductCategory(models.Model): + name = models.CharField(max_length=255) + main_category = models.BooleanField(default=True) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dashboard:category-detail', kwargs={'pk': self.pk}) + + class Meta: + verbose_name = 'Product Category' + verbose_name_plural = 'Product Categories' class Product(models.Model): + category = models.ForeignKey( + ProductCategory, + blank=True, + null=True, + on_delete=models.SET_NULL + ) name = models.CharField(max_length=250) subtitle = models.CharField(max_length=250, blank=True) description = models.TextField(blank=True) - sku = models.CharField(max_length=255, unique=True) - price = models.DecimalField( - max_digits=settings.DEFAULT_MAX_DIGITS, - decimal_places=settings.DEFAULT_DECIMAL_PLACES, - blank=True, - null=True, - ) - weight = MeasurementField( - measurement=Weight, - unit_choices=WeightUnits.CHOICES, - blank=True, - null=True + checkout_limit = models.IntegerField( + default=0, + validators=[MinValueValidator(0)] ) visible_in_listings = models.BooleanField(default=False) @@ -65,8 +92,6 @@ class Product(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - objects = ProductManager() - def __str__(self): return self.name @@ -86,6 +111,73 @@ class Product(models.Model): ordering = ['sorting', 'name'] +class ProductVariantManager(models.Manager): + def get_queryset(self): + return super().get_queryset().annotate( + num_ordered=models.Sum('order_lines__quantity') + ) + + +class ProductVariant(models.Model): + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='variants' + ) + name = models.CharField(max_length=255) + sku = models.CharField(max_length=255, unique=True) + stripe_id = models.CharField(max_length=255, blank=True) + price = models.DecimalField( + max_digits=settings.DEFAULT_MAX_DIGITS, + decimal_places=settings.DEFAULT_DECIMAL_PLACES, + blank=True, + null=True, + ) + weight = MeasurementField( + measurement=Weight, + unit_choices=WeightUnits.CHOICES, + blank=True, + null=True + ) + track_inventory = models.BooleanField(default=False) + stock = models.IntegerField( + blank=True, + null=True, + validators=[MinValueValidator(0)] + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = ProductVariantManager() + + def __str__(self): + return f'{self.product}: {self.name}' + + class Meta: + ordering = ['weight'] + + +class ProductOption(models.Model): + """ + Description: Consistent accross all variants + """ + products = models.ManyToManyField( + Product, + related_name='options' + ) + name = models.CharField(max_length=255) + options = ArrayField( + models.CharField(max_length=255) + ) + + def get_absolute_url(self): + return reverse('dashboard:option-detail', kwargs={'pk': self.pk}) + + def __str__(self): + return f'{self.name}' + + class ProductPhoto(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) image = models.ImageField(upload_to='products/images') @@ -148,17 +240,36 @@ class Coupon(models.Model): return reverse('dashboard:coupon-detail', kwargs={'pk': self.pk}) -class ShippingMethod(models.Model): - name = models.CharField(max_length=100) - type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES) - price = models.DecimalField( - max_digits=settings.DEFAULT_MAX_DIGITS, - decimal_places=settings.DEFAULT_DECIMAL_PLACES, - default=0, +class ShippingRate(models.Model): + shipping_provider = models.CharField( + max_length=255, + choices=ShippingProvider.CHOICES, + default=ShippingProvider.USPS ) + name = models.CharField(max_length=255) + container = models.CharField( + max_length=255, + choices=ShippingContainer.CHOICES, + default=ShippingContainer.VARIABLE + ) + min_order_weight = models.PositiveIntegerField( + blank=True, + null=True + ) + max_order_weight = models.PositiveIntegerField( + blank=True, + null=True + ) + is_selectable = models.BooleanField(default=True) def get_absolute_url(self): - return reverse('dashboard:shipmeth-detail', kwargs={'pk': self.pk}) + return reverse('dashboard:rate-detail', kwargs={'pk': self.pk}) + + def __str__(self): + return f'{self.shipping_provider}: {self.name} ({self.min_order_weight}–{self.max_order_weight})' + + class Meta: + ordering = ['min_order_weight'] class OrderManager(models.Manager): @@ -186,7 +297,7 @@ class OrderManager(models.Manager): class Order(models.Model): customer = models.ForeignKey( User, - related_name="orders", + related_name='orders', on_delete=models.SET_NULL, null=True ) @@ -209,14 +320,6 @@ class Order(models.Model): null=True, on_delete=models.SET_NULL ) - shipping_method = models.ForeignKey( - ShippingMethod, - blank=True, - null=True, - related_name="orders", - on_delete=models.SET_NULL - ) - coupon = models.ForeignKey( Coupon, related_name='orders', @@ -224,19 +327,22 @@ class Order(models.Model): blank=True, null=True ) - + subtotal_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0 + ) + coupon_amount = models.CharField(max_length=255, blank=True) shipping_total = models.DecimalField( max_digits=5, decimal_places=2, default=0 ) - - total_net_amount = models.DecimalField( + total_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0 ) - weight = MeasurementField( measurement=Weight, unit_choices=WeightUnits.CHOICES, @@ -250,6 +356,14 @@ class Order(models.Model): objects = OrderManager() + def minus_stock(self): + for line in self.lines.all(): + line.minus_stock() + + def add_stock(self): + for line in self.lines.all(): + line.add_stock() + def get_total_quantity(self): return sum([line.quantity for line in self]) @@ -258,11 +372,11 @@ class Order(models.Model): if self.coupon.discount_value_type == DiscountValueType.FIXED: return self.coupon.discount_value elif self.coupon.discount_value_type == DiscountValueType.PERCENTAGE: - return (self.coupon.discount_value / Decimal('100')) * self.total_net_amount + return (self.coupon.discount_value / Decimal('100')) * self.subtotal_amount return Decimal('0') def get_total_price_after_discount(self): - return round((self.total_net_amount - self.get_discount()) + self.shipping_total, 2) + return round((self.subtotal_amount - self.get_discount()) + self.shipping_total, 2) def get_absolute_url(self): return reverse('dashboard:order-detail', kwargs={'pk': self.pk}) @@ -292,13 +406,13 @@ class Transaction(models.Model): class OrderLine(models.Model): order = models.ForeignKey( Order, - related_name="lines", + related_name='lines', editable=False, on_delete=models.CASCADE ) - product = models.ForeignKey( - Product, - related_name="order_lines", + variant = models.ForeignKey( + ProductVariant, + related_name='order_lines', on_delete=models.SET_NULL, blank=True, null=True, @@ -307,20 +421,17 @@ class OrderLine(models.Model): quantity_fulfilled = models.IntegerField( validators=[MinValueValidator(0)], default=0 ) - customer_note = models.TextField(blank=True, default="") - + customer_note = models.TextField(blank=True, default='') currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) - unit_price = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) - tax_rate = models.DecimalField( - max_digits=5, decimal_places=2, default=Decimal("0.0") + max_digits=5, decimal_places=2, default=Decimal('0.0') ) def get_total(self): @@ -330,6 +441,16 @@ class OrderLine(models.Model): def quantity_unfulfilled(self): return self.quantity - self.quantity_fulfilled + def minus_stock(self): + if self.variant.track_inventory: + self.variant.stock -= self.quantity + self.variant.save() + + def add_stock(self): + if self.variant.track_inventory: + self.variant.stock += self.quantity + self.variant.save() + class TrackingNumber(models.Model): order = models.ForeignKey( @@ -349,3 +470,31 @@ class TrackingNumber(models.Model): def __str__(self): return self.tracking_id + + +class Subscription(models.Model): + stripe_id = models.CharField(max_length=255, blank=True) + customer = models.OneToOneField( + User, + related_name='subscription', + on_delete=models.SET_NULL, + null=True + ) + + +class SiteSettings(SingletonBase): + usps_user_id = models.CharField(max_length=255) + default_shipping_rate = models.ForeignKey( + ShippingRate, + blank=True, + null=True, + related_name='+', + on_delete=models.SET_NULL + ) + + def __str__(self): + return 'Site Settings' + + class Meta: + verbose_name = 'Site Settings' + verbose_name_plural = 'Site Settings' diff --git a/src/core/signals.py b/src/core/signals.py index 67e1d60..bdd99f4 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -1,12 +1,16 @@ import logging +import stripe from io import BytesIO from django.db.models.signals import post_save from django.dispatch import receiver from django.db import models +from django.conf import settings from . import OrderStatus, TransactionStatus -from .models import Order, OrderLine, Transaction, TrackingNumber +from .models import ( + Product, ProductVariant, Order, OrderLine, Transaction, TrackingNumber +) from .tasks import ( send_order_confirmation_email, send_order_shipped_email @@ -14,6 +18,36 @@ from .tasks import ( logger = logging.getLogger(__name__) + +# @receiver(post_save, sender=ProductVariant, dispatch_uid='variant_created') +# def variant_saved(sender, instance, created, **kwargs): +# logger.info('Product was saved') +# if created or not instance.stripe_id: +# stripe.api_key = settings.STRIPE_API_KEY +# prod_response = stripe.Product.create( +# name=instance.product.name + ': ' + instance.name, +# description=instance.product.description +# ) +# price_response = stripe.Price.create( +# unit_amount=int(instance.price * 100), +# currency=settings.DEFAULT_CURRENCY, +# product=prod_response['id'] +# ) +# instance.stripe_id = prod_response['id'] +# instance.stripe_price_id = price_response['id'] +# instance.save() +# else: +# stripe.Product.modify( +# instance.stripe_id, +# name=instance.product.name + ': ' + instance.name, +# description=instance.product.description +# ) +# stripe.Price.modify( +# instance.stripe_price_id, +# unit_amount=int(instance.price * 100) +# ) + + @receiver(post_save, sender=Order, dispatch_uid="order_created") def order_created(sender, instance, created, **kwargs): if created: @@ -37,6 +71,7 @@ def transaction_created(sender, instance, created, **kwargs): instance.confirmation_email_sent = True instance.save() + @receiver(post_save, sender=TrackingNumber, dispatch_uid="trackingnumber_postsave") def trackingnumber_postsave(sender, instance, created, **kwargs): if created: @@ -59,6 +94,7 @@ def get_order_status(total_quantity_fulfilled, total_quantity_ordered): else: return OrderStatus.UNFULFILLED + @receiver(post_save, sender=OrderLine, dispatch_uid="order_line_post_save") def order_line_post_save(sender, instance, created, **kwargs): if not created: diff --git a/src/core/tasks.py b/src/core/tasks.py index 91a5ac1..5d47ae4 100644 --- a/src/core/tasks.py +++ b/src/core/tasks.py @@ -15,6 +15,7 @@ SHIP_ORDER_TEMPLATE = 'storefront/order_shipped' ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' ORDER_REFUND_TEMPLATE = 'storefront/order_refund' + @shared_task(name='send_order_confirmation_email') def send_order_confirmation_email(order): send_templated_mail( @@ -26,6 +27,7 @@ def send_order_confirmation_email(order): logger.info(f"Order confirmation email sent to {order['email']}") + @shared_task(name='send_order_shipped_email') def send_order_shipped_email(data): send_templated_mail( diff --git a/src/core/tests/test_models.py b/src/core/tests/test_models.py index 59935f9..841f14f 100644 --- a/src/core/tests/test_models.py +++ b/src/core/tests/test_models.py @@ -7,7 +7,7 @@ from core.models import ( Product, ProductPhoto, Coupon, - ShippingMethod, + ShippingRate, Order, Transaction, OrderLine, diff --git a/src/core/weight.py b/src/core/weight.py index baf9dd0..a3a6dc3 100644 --- a/src/core/weight.py +++ b/src/core/weight.py @@ -1,14 +1,15 @@ from measurement.measures import Weight + class WeightUnits: # KILOGRAM = "kg" - # POUND = "lb" + POUND = "lb" OUNCE = "oz" # GRAM = "g" CHOICES = [ # (KILOGRAM, "kg"), - # (POUND, "lb"), + (POUND, "lb"), (OUNCE, "oz"), # (GRAM, "g"), ] diff --git a/src/dashboard/forms.py b/src/dashboard/forms.py index 56aaa53..3d75eff 100644 --- a/src/dashboard/forms.py +++ b/src/dashboard/forms.py @@ -5,7 +5,7 @@ from core import OrderStatus from core.models import ( Order, OrderLine, - ShippingMethod, + ShippingRate, TrackingNumber, Coupon, ProductPhoto diff --git a/src/dashboard/templates/dashboard/catalog.html b/src/dashboard/templates/dashboard/catalog.html new file mode 100644 index 0000000..99bfced --- /dev/null +++ b/src/dashboard/templates/dashboard/catalog.html @@ -0,0 +1,76 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Catalog

+
+ + New product option + + New category + + New product +
+
+ {% for category in category_list %} +
+
+ + Category: +

{{ category }}

+
+ Name + Visible in listings +
+ {% for product in category.product_set.all %} + +
+ {{product.get_first_img.image}} +
+ {{product.name}} + {{product.visible_in_listings|yesno:"Yes,No"}} +
+ {% endfor %} +
+ {% endfor %} +
+
+
+

Uncategorized Products

+
+
+
+ + Name + Visible + Price +
+ {% for product in uncategorized_products %} + +
+ {{product.get_first_img.image}} +
+ {{product.name}} + {{product.visible_in_listings|yesno:"Yes,No"}} + ${{product.price}} +
+ {% endfor %} +
+
+
+
+

Product Options

+
+
+
+ + Name +
+ {% for option in option_list %} + + {{option.name}} + {{ option.options }} + + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/category_confirm_delete.html b/src/dashboard/templates/dashboard/category_confirm_delete.html new file mode 100644 index 0000000..2afeb77 --- /dev/null +++ b/src/dashboard/templates/dashboard/category_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

{{ category }}

+
+
+
{% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/category_create_form.html b/src/dashboard/templates/dashboard/category_create_form.html new file mode 100644 index 0000000..3cff841 --- /dev/null +++ b/src/dashboard/templates/dashboard/category_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Create category

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/category_detail.html b/src/dashboard/templates/dashboard/category_detail.html new file mode 100644 index 0000000..3629f8f --- /dev/null +++ b/src/dashboard/templates/dashboard/category_detail.html @@ -0,0 +1,24 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+
+

{{ category.name }}

+

Is a main category: {{ category.main_category|yesno:"Yes,No" }}

+
+
+ Delete + Edit +
+
+
+ {% for product in category.product_set.all %} + {{ product }} + {% empty %} +

No products

+ {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/category_form.html b/src/dashboard/templates/dashboard/category_form.html new file mode 100644 index 0000000..b71aa94 --- /dev/null +++ b/src/dashboard/templates/dashboard/category_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update category

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/category_list.html b/src/dashboard/templates/dashboard/category_list.html new file mode 100644 index 0000000..62f826d --- /dev/null +++ b/src/dashboard/templates/dashboard/category_list.html @@ -0,0 +1,25 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Categories

+ +
+
+
+ Name +
+ {% for category in category_list %} + + {{ category.name }} + + {% empty %} + No categories + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/config.html b/src/dashboard/templates/dashboard/config.html index 0874ed3..5e56eb0 100644 --- a/src/dashboard/templates/dashboard/config.html +++ b/src/dashboard/templates/dashboard/config.html @@ -10,27 +10,18 @@
-

Shipping methods

- + New method +

Shipping rates

+ + New rate
- {% for method in shipping_method_list %} + {% for rate in shipping_rate_list %}

- {{method.name}} | {{method.type}} | {{method.price}} + {{ rate }}

{% empty %} -

No shipping methods yet.

+

No shipping rates yet.

{% endfor %}
- -
-
-

Staff

- + New staff -
-
-
-
{% endblock %} diff --git a/src/dashboard/templates/dashboard/customer_detail.html b/src/dashboard/templates/dashboard/customer_detail.html index e724a28..3fefbbf 100644 --- a/src/dashboard/templates/dashboard/customer_detail.html +++ b/src/dashboard/templates/dashboard/customer_detail.html @@ -51,6 +51,10 @@

No other addresses.

{% endfor %} +
+ Stripe ID
+

{{ customer.stripe_id }}

+
{% with order_list=customer.orders.all %}
@@ -67,7 +71,7 @@
{{order.get_status_display}}
- ${{order.total_net_amount}} + ${{order.total_amount}} {% empty %} No orders diff --git a/src/dashboard/templates/dashboard/customer_list.html b/src/dashboard/templates/dashboard/customer_list.html index 5387de4..d7aac40 100644 --- a/src/dashboard/templates/dashboard/customer_list.html +++ b/src/dashboard/templates/dashboard/customer_list.html @@ -22,5 +22,24 @@ No customers {% endfor %}
+
+ +
{% endblock content %} diff --git a/src/dashboard/templates/dashboard/option_confirm_delete.html b/src/dashboard/templates/dashboard/option_confirm_delete.html new file mode 100644 index 0000000..fb744f8 --- /dev/null +++ b/src/dashboard/templates/dashboard/option_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Option

+
+
+
{% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/option_create_form.html b/src/dashboard/templates/dashboard/option_create_form.html new file mode 100644 index 0000000..b6ab6db --- /dev/null +++ b/src/dashboard/templates/dashboard/option_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Create option

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/option_detail.html b/src/dashboard/templates/dashboard/option_detail.html new file mode 100644 index 0000000..d5e231b --- /dev/null +++ b/src/dashboard/templates/dashboard/option_detail.html @@ -0,0 +1,24 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

{{ option.name }}

+
+ Delete + Edit +
+
+
+
+

Products

+
+ {% for product in option.products.all %} + + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/option_form.html b/src/dashboard/templates/dashboard/option_form.html new file mode 100644 index 0000000..84f5538 --- /dev/null +++ b/src/dashboard/templates/dashboard/option_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update option

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/order_detail.html b/src/dashboard/templates/dashboard/order_detail.html index ac5f882..c530b70 100644 --- a/src/dashboard/templates/dashboard/order_detail.html +++ b/src/dashboard/templates/dashboard/order_detail.html @@ -4,15 +4,12 @@ {% block content %}
-

Order #{{order.pk}}

+
+

Order #{{order.pk}}

+

Date: {{ order.created_at }}

+
- + Cancel order {{order.get_status_display}} ({{order.total_quantity_fulfilled}} / {{order.total_quantity_ordered}})
@@ -26,14 +23,14 @@ {% for item in order.lines.all %}
- {% with product=item.product %} + {% with product=item.variant.product %}
{{product.get_first_img.image}} -
{{product.name}}
Grind: {{item.customer_note}}
+
{{item.variant}}
{{item.customer_note}}
{{product.sku}} {{item.quantity}} - ${{product.price}} + ${{item.variant.price}} ${{item.get_total}} {% endwith %}
@@ -103,12 +100,12 @@

- Subtotal: ${{order.total_net_amount}}
+ Subtotal: ${{order.subtotal_amount}}
{% if order.coupon %} Discount: {{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}
{% endif %} Shipping: ${{order.shipping_total}}
- Total: ${{order.get_total_price_after_discount}} + Total: ${{order.total_amount}}

diff --git a/src/dashboard/templates/dashboard/order_fulfill.html b/src/dashboard/templates/dashboard/order_fulfill.html index 2e7b351..339947d 100644 --- a/src/dashboard/templates/dashboard/order_fulfill.html +++ b/src/dashboard/templates/dashboard/order_fulfill.html @@ -7,7 +7,6 @@
{% csrf_token %} {{ form.management_form }} -
{% for dict in form.errors %} {% for error in dict.values %} @@ -20,15 +19,15 @@ Product SKU Quantity to fulfill - Grind + Options {% for form in form %}
- {% with product=form.instance.product %} + {% with product=form.instance.variant.product %} {{form.id}}
{{product.get_first_img.image}} -
{{product.name}}
+
{{form.instance.variant}}
{{product.sku}} {{form.quantity_fulfilled}} / {{form.instance.quantity}} diff --git a/src/dashboard/templates/dashboard/product_detail.html b/src/dashboard/templates/dashboard/product_detail.html index 17f56c4..4279395 100644 --- a/src/dashboard/templates/dashboard/product_detail.html +++ b/src/dashboard/templates/dashboard/product_detail.html @@ -15,14 +15,46 @@ {{product.get_first_img.image}}
+

Category: {{ product.category }}

{{product.name}}

+
{{ product.subtitle }}

{{product.description}}

-

${{product.price}}

-

{{product.weight.oz}} oz

+

Checkout limit: {{ product.checkout_limit }}

Visible in listings: {{product.visible_in_listings|yesno:"Yes,No"}}

+

Sorting: {{ product.sorting }}

Ordered {{product.num_ordered|default_if_none:"0"}} time{{ product.num_ordered|default_if_none:"0"|pluralize }}.

+
+
+

Variants

+ + New variant +
+ {% for variant in product.variants.all %} +
+

{{ variant.name }}

+

SKU: {{ variant.sku }}

+

Price: ${{ variant.price }}

+

Weight: {{ variant.weight }}

+ {% if variant.track_inventory %} +

Stock: {{ variant.stock }}

+ {% endif %} +

Edit

+
+ {% endfor %} +
+
+
+

Options

+

To create more product options go to the catalog

+
+ {% for option in product.options.all %} +
+

{{ option.name }}

+

{{ option.options }}

+
+ {% endfor %} +

Photos

diff --git a/src/dashboard/templates/dashboard/product_list.html b/src/dashboard/templates/dashboard/product_list.html index 2f266d3..06e75b1 100644 --- a/src/dashboard/templates/dashboard/product_list.html +++ b/src/dashboard/templates/dashboard/product_list.html @@ -4,24 +4,23 @@ {% block content %}
-

Catalog

+

Catalog

+ + New category + New product
-
+
Name Visible - Price
{% for product in product_list %} - +
{{product.get_first_img.image}}
{{product.name}} {{product.visible_in_listings|yesno:"Yes,No"}} - ${{product.price}}
{% endfor %}
diff --git a/src/dashboard/templates/dashboard/rate_confirm_delete.html b/src/dashboard/templates/dashboard/rate_confirm_delete.html new file mode 100644 index 0000000..446ee6b --- /dev/null +++ b/src/dashboard/templates/dashboard/rate_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

{{ rate }}

+
+
+ {% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+ +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/shipmeth_create_form.html b/src/dashboard/templates/dashboard/rate_create_form.html similarity index 62% rename from src/dashboard/templates/dashboard/shipmeth_create_form.html rename to src/dashboard/templates/dashboard/rate_create_form.html index 88cd9bc..7fbbca5 100644 --- a/src/dashboard/templates/dashboard/shipmeth_create_form.html +++ b/src/dashboard/templates/dashboard/rate_create_form.html @@ -2,13 +2,13 @@ {% block content %}
-

Create Shipping Method

+

Create Shipping Rate

-
+ {% csrf_token %} {{form.as_p}}

- or cancel + or cancel

diff --git a/src/dashboard/templates/dashboard/rate_detail.html b/src/dashboard/templates/dashboard/rate_detail.html new file mode 100644 index 0000000..421280a --- /dev/null +++ b/src/dashboard/templates/dashboard/rate_detail.html @@ -0,0 +1,22 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Shipping Rate

+
+ Delete + Edit +
+
+
+
+

{{rate.name}}

+

Shipping Provider: {{ rate.shipping_provider }}

+

Container: {{ rate.get_container_display }}

+

Weight range: {{ rate.min_order_weight }} – {{ rate.max_order_weight }}

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/rate_form.html b/src/dashboard/templates/dashboard/rate_form.html new file mode 100644 index 0000000..0947361 --- /dev/null +++ b/src/dashboard/templates/dashboard/rate_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update rate

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/shipmeth_detail.html b/src/dashboard/templates/dashboard/shipmeth_detail.html deleted file mode 100644 index dad3e49..0000000 --- a/src/dashboard/templates/dashboard/shipmeth_detail.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "dashboard.html" %} -{% load static %} - -{% block content %} -
-
-

Shipping Method

-
- Delete - Edit -
-
-
-
-

{{shippingmethod.name}}

-

{{shippingmethod.get_type_display}}

-

${{shippingmethod.price}}

-
-
-
-{% endblock content %} diff --git a/src/dashboard/templates/dashboard/stock.html b/src/dashboard/templates/dashboard/stock.html new file mode 100644 index 0000000..b846316 --- /dev/null +++ b/src/dashboard/templates/dashboard/stock.html @@ -0,0 +1,34 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Stock

+

Total in warehouse = available stock + unfulfilled

+
+ +
+
+ Product + SKU + Available Stock + Total in warehouse +
+ {% for variant in variant_list %} +
+ {% with product=variant.product %} +
+ {{product.get_first_img.image}} +
{{variant}}
+
+ {{ variant.sku }} + {{ variant.stock }} + {{ variant.total_in_warehouse }} + Restock → + {% endwith %} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/variant_confirm_delete.html b/src/dashboard/templates/dashboard/variant_confirm_delete.html new file mode 100644 index 0000000..365b892 --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_confirm_delete.html @@ -0,0 +1,20 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Delete Variant

+
+
+
+ {% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/variant_create_form.html b/src/dashboard/templates/dashboard/variant_create_form.html new file mode 100644 index 0000000..ae94ae7 --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Create variant

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/variant_form.html b/src/dashboard/templates/dashboard/variant_form.html new file mode 100644 index 0000000..44107d8 --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_form.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update variant

+ Delete +
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/variant_restock.html b/src/dashboard/templates/dashboard/variant_restock.html new file mode 100644 index 0000000..c75f805 --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_restock.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Restock variant

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

Total in warehouse: {{ variant.total_in_warehouse }}

+

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/tests/test_views.py b/src/dashboard/tests/test_views.py index ab814ad..b054ba7 100644 --- a/src/dashboard/tests/test_views.py +++ b/src/dashboard/tests/test_views.py @@ -20,8 +20,8 @@ from dashboard.forms import ( from dashboard.views import ( DashboardHomeView, DashboardConfigView, - ShippingMethodCreateView, - ShippingMethodDetailView, + ShippingRateCreateView, + ShippingRateDetailView, CouponListView, CouponCreateView, CouponDetailView, diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py index 20a4d32..dc2d70c 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -2,47 +2,245 @@ from django.urls import path, include from . import views urlpatterns = [ - path('', views.DashboardHomeView.as_view(), name='home'), - path('config/', views.DashboardConfigView.as_view(), name='config'), + path( + '', + views.DashboardHomeView.as_view(), + name='home' + ), + path( + 'config/', + views.DashboardConfigView.as_view(), + name='config' + ), + path( + 'catalog/', + views.CatalogView.as_view(), + name='catalog' + ), + path( + 'stock/', + views.StockView.as_view(), + name='stock' + ), - path('shipping-methods/new/', views.ShippingMethodCreateView.as_view(), name='shipmeth-create'), - path('shipping-methods//', include([ - path('', views.ShippingMethodDetailView.as_view(), name='shipmeth-detail'), + path( + 'shipping-rates/new/', + views.ShippingRateCreateView.as_view(), + name='rate-create' + ), + path('shipping-rates//', include([ + path( + '', + views.ShippingRateDetailView.as_view(), + name='rate-detail' + ), + path( + 'update/', + views.ShippingRateUpdateView.as_view(), + name='rate-update' + ), + path( + 'delete/', + views.ShippingRateDeleteView.as_view(), + name='rate-delete' + ), ])), - path('coupons/', views.CouponListView.as_view(), name='coupon-list'), - path('coupons/new/', views.CouponCreateView.as_view(), name='coupon-create'), + path( + 'coupons/', + views.CouponListView.as_view(), + name='coupon-list' + ), + path( + 'coupons/new/', + views.CouponCreateView.as_view(), + name='coupon-create' + ), path('coupons//', include([ - path('', views.CouponDetailView.as_view(), name='coupon-detail'), - path('update/', views.CouponUpdateView.as_view(), name='coupon-update'), - path('delete/', views.CouponDeleteView.as_view(), name='coupon-delete'), + path( + '', + views.CouponDetailView.as_view(), + name='coupon-detail' + ), + path( + 'update/', + views.CouponUpdateView.as_view(), + name='coupon-update' + ), + path( + 'delete/', + views.CouponDeleteView.as_view(), + name='coupon-delete' + ), ])), - path('orders/', views.OrderListView.as_view(), name='order-list'), + path( + 'orders/', + views.OrderListView.as_view(), + name='order-list' + ), path('orders//', include([ - path('', views.OrderDetailView.as_view(), name='order-detail'), - path('fulfill/', views.OrderFulfillView.as_view(), name='order-fulfill'), - path('cancel/', views.OrderCancelView.as_view(), name='order-cancel'), - path('ship/', views.OrderTrackingView.as_view(), name='order-ship'), + path( + '', + views.OrderDetailView.as_view(), + name='order-detail' + ), + path( + 'fulfill/', + views.OrderFulfillView.as_view(), + name='order-fulfill' + ), + path( + 'cancel/', + views.OrderCancelView.as_view(), + name='order-cancel' + ), + path( + 'ship/', + views.OrderTrackingView.as_view(), + name='order-ship' + ), ])), - path('products/', views.ProductListView.as_view(), name='product-list'), - path('products/new/', views.ProductCreateView.as_view(), name='product-create'), - path('products//', include([ - path('', views.ProductDetailView.as_view(), name='product-detail'), - path('update/', views.ProductUpdateView.as_view(), name='product-update'), - path('delete/', views.ProductDeleteView.as_view(), name='product-delete'), - - path('photos/new/', views.ProductPhotoCreateView.as_view(), name='prodphoto-create'), - path('photos//', include([ - path('delete/', views.ProductPhotoDeleteView.as_view(), name='prodphoto-delete'), + # Categories + path('categories/', include([ + path( + '', + views.CategoryListView.as_view(), + name='category-list' + ), + path( + 'new/', + views.CategoryCreateView.as_view(), + name='category-create' + ), + path('/', include([ + path( + '', + views.CategoryDetailView.as_view(), + name='category-detail' + ), + path( + 'update/', + views.CategoryUpdateView.as_view(), + name='category-update' + ), + path( + 'delete/', + views.CategoryDeleteView.as_view(), + name='category-delete' + ), ])), ])), - path('customers/', views.CustomerListView.as_view(), name='customer-list'), + path( + 'products/', + views.ProductListView.as_view(), + name='product-list' + ), + path( + 'products/new/', + views.ProductCreateView.as_view(), + name='product-create' + ), + path('products//', include([ + path( + '', + views.ProductDetailView.as_view(), + name='product-detail' + ), + path( + 'update/', + views.ProductUpdateView.as_view(), + name='product-update' + ), + path( + 'delete/', + views.ProductDeleteView.as_view(), + name='product-delete' + ), + + path( + 'photos/new/', + views.ProductPhotoCreateView.as_view(), + name='prodphoto-create' + ), + path('photos//', include([ + path( + 'delete/', + views.ProductPhotoDeleteView.as_view(), + name='prodphoto-delete' + ), + ])), + + # ProductVariants + path('variants/', include([ + path( + 'new/', + views.ProductVariantCreateView.as_view(), + name='variant-create' + ), + path('/', include([ + path( + 'update/', + views.ProductVariantUpdateView.as_view(), + name='variant-update' + ), + path( + 'delete/', + views.ProductVariantDeleteView.as_view(), + name='variant-delete' + ), + path( + 'restock/', + views.ProductVariantStockUpdateView.as_view(), + name='variant-restock' + ), + ])), + ])), + ])), + + # ProductOptions + path('options/', include([ + path( + 'new/', + views.ProductOptionCreateView.as_view(), + name='option-create' + ), + path('/', include([ + path( + '', + views.ProductOptionDetailView.as_view(), + name='option-detail' + ), + path( + 'update/', + views.ProductOptionUpdateView.as_view(), + name='option-update' + ), + path( + 'delete/', + views.ProductOptionDeleteView.as_view(), + name='option-delete' + ), + ])), + ])), + + path( + 'customers/', + views.CustomerListView.as_view(), + name='customer-list' + ), path('customers//', include([ - path('', views.CustomerDetailView.as_view(), name='customer-detail'), - path('update/', views.CustomerUpdateView.as_view(), name='customer-update'), - # path('delete/', views.CustomerDeleteView.as_view(), name='customer-delete'), + path( + '', + views.CustomerDetailView.as_view(), + name='customer-detail' + ), + path( + 'update/', + views.CustomerUpdateView.as_view(), + name='customer-update' + ), ])), ] diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 80f18ef..1ceb8cd 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -18,7 +18,8 @@ from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.db.models import ( - Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value + Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value, + ExpressionWrapper, IntegerField ) from django.db.models.functions import Coalesce @@ -26,11 +27,14 @@ from accounts.models import User from accounts.utils import get_or_create_customer from accounts.forms import AddressForm from core.models import ( + ProductCategory, Product, ProductPhoto, + ProductVariant, + ProductOption, Order, OrderLine, - ShippingMethod, + ShippingRate, Transaction, TrackingNumber, Coupon @@ -39,8 +43,7 @@ from core.models import ( from core import ( DiscountValueType, VoucherType, - OrderStatus, - ShippingMethodType + OrderStatus ) from .forms import ( OrderLineFulfillForm, @@ -72,7 +75,7 @@ class DashboardHomeView(LoginRequiredMixin, TemplateView): status=OrderStatus.DRAFT ).filter( created_at__date=today - ).aggregate(total=Sum('total_net_amount'))['total'] + ).aggregate(total=Sum('total_amount'))['total'] return context @@ -81,23 +84,71 @@ class DashboardConfigView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - today = timezone.localtime(timezone.now()).date() - - context['shipping_method_list'] = ShippingMethod.objects.all() - + context['shipping_rate_list'] = ShippingRate.objects.all() return context -class ShippingMethodCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): - model = ShippingMethod - template_name = 'dashboard/shipmeth_create_form.html' +class CatalogView(ListView): + model = ProductCategory + context_object_name = 'category_list' + template_name = 'dashboard/catalog.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['uncategorized_products'] = Product.objects.filter( + category=None + ) + context['option_list'] = ProductOption.objects.all() + return context + + +class StockView(ListView): + model = ProductVariant + context_object_name = 'variant_list' + template_name = 'dashboard/stock.html' + + def get_queryset(self): + object_list = ProductVariant.objects.filter( + track_inventory=True + ).prefetch_related('order_lines', 'product').annotate( + total_in_warehouse=F('stock') + Coalesce(Sum('order_lines__quantity', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED) + ) - Sum('order_lines__quantity_fulfilled', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED)), 0) + ).order_by('product') + return object_list + + +class ShippingRateDetailView(LoginRequiredMixin, DetailView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_detail.html' + + +class ShippingRateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_create_form.html' fields = '__all__' success_message = '%(name)s created.' -class ShippingMethodDetailView(LoginRequiredMixin, DetailView): - model = ShippingMethod - template_name = 'dashboard/shipmeth_detail.html' +class ShippingRateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_form.html' + success_message = 'ShippingRate saved.' + fields = '__all__' + + +class ShippingRateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_confirm_delete.html' + success_message = 'ShippingRate deleted.' + success_url = reverse_lazy('dashboard:config') class CouponListView(LoginRequiredMixin, ListView): @@ -168,10 +219,9 @@ class OrderDetailView(LoginRequiredMixin, DetailView): ).select_related( 'customer', 'billing_address', - 'shipping_address', - 'shipping_method' + 'shipping_address' ).prefetch_related( - 'lines__product__productphoto_set' + 'lines__variant__product__productphoto_set' ) obj = queryset.get() return obj @@ -183,9 +233,9 @@ class OrderDetailView(LoginRequiredMixin, DetailView): class OrderFulfillView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Order - template_name = "dashboard/order_fulfill.html" + template_name = 'dashboard/order_fulfill.html' form_class = OrderLineFormset - success_message = "Order saved." + success_message = 'Order saved.' def form_valid(self, form): form.save() @@ -204,6 +254,11 @@ class OrderCancelView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): 'status': OrderStatus.CANCELED } + def form_valid(self, form): + form.instance.add_stock() + form.instance.save() + return super().form_valid(form) + def get_success_url(self): return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) @@ -222,6 +277,42 @@ class OrderTrackingView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) +class CategoryListView(ListView): + model = ProductCategory + context_object_name = 'category_list' + template_name = 'dashboard/category_list.html' + + +class CategoryCreateView(SuccessMessageMixin, CreateView): + model = ProductCategory + context_object_name = 'category' + success_message = 'Category created.' + template_name = 'dashboard/category_create_form.html' + fields = '__all__' + + +class CategoryDetailView(DetailView): + model = ProductCategory + context_object_name = 'category' + template_name = 'dashboard/category_detail.html' + + +class CategoryUpdateView(SuccessMessageMixin, UpdateView): + model = ProductCategory + context_object_name = 'category' + success_message = 'Category saved.' + template_name = 'dashboard/category_form.html' + fields = '__all__' + + +class CategoryDeleteView(SuccessMessageMixin, DeleteView): + model = ProductCategory + context_object_name = 'category' + success_message = 'Category deleted.' + template_name = 'dashboard/category_confirm_delete.html' + success_url = reverse_lazy('dashboard:catalog') + + class ProductListView(LoginRequiredMixin, ListView): model = Product template_name = 'dashboard/product_list.html' @@ -240,6 +331,20 @@ class ProductDetailView(LoginRequiredMixin, DetailView): model = Product template_name = 'dashboard/product_detail.html' + def get_object(self): + pk = self.kwargs.get(self.pk_url_kwarg) + queryset = Product.objects.filter( + pk=pk + ).select_related( + 'category', + ).prefetch_related( + 'variants', + 'options', + 'productphoto_set' + ) + obj = queryset.get() + return obj + class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Product @@ -292,9 +397,149 @@ class ProductPhotoDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) +class ProductVariantCreateView(SuccessMessageMixin, CreateView): + model = ProductVariant + success_message = 'Variant created.' + template_name = 'dashboard/variant_create_form.html' + fields = [ + 'name', + 'sku', + 'price', + 'weight', + 'track_inventory', + 'stock', + ] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def form_valid(self, form): + form.instance.product = Product.objects.get(pk=self.kwargs['pk']) + return super().form_valid(form) + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + +class ProductVariantUpdateView(SuccessMessageMixin, UpdateView): + model = ProductVariant + pk_url_kwarg = 'variant_pk' + success_message = 'ProductVariant saved.' + template_name = 'dashboard/variant_form.html' + fields = [ + 'name', + 'sku', + 'price', + 'weight', + 'track_inventory', + 'stock', + ] + context_object_name = 'variant' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def form_valid(self, form): + form.instance.product = Product.objects.get(pk=self.kwargs['pk']) + return super().form_valid(form) + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + +class ProductVariantDeleteView(SuccessMessageMixin, DeleteView): + model = ProductVariant + pk_url_kwarg = 'variant_pk' + success_message = 'ProductVariant deleted.' + template_name = 'dashboard/variant_confirm_delete.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + +class ProductVariantStockUpdateView(LoginRequiredMixin, UpdateView): + model = ProductVariant + pk_url_kwarg = 'variant_pk' + success_message = 'ProductVariant saved.' + success_url = reverse_lazy('dashboard:stock') + template_name = 'dashboard/variant_restock.html' + fields = [ + 'stock', + ] + context_object_name = 'variant' + + def get_queryset(self): + queryset = ProductVariant.objects.annotate( + total_in_warehouse=F('stock') + Coalesce(Sum('order_lines__quantity', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED) + ) - Sum('order_lines__quantity_fulfilled', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED)), 0) + ).prefetch_related('order_lines', 'product') + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def form_valid(self, form): + form.instance.product = Product.objects.get(pk=self.kwargs['pk']) + return super().form_valid(form) + + +class ProductOptionDetailView(LoginRequiredMixin, DetailView): + model = ProductOption + template_name = 'dashboard/option_detail.html' + context_object_name = 'option' + + +class ProductOptionCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ProductOption + template_name = 'dashboard/option_create_form.html' + fields = [ + 'name', + 'options', + 'products', + ] + success_message = '%(name)s created.' + + +class ProductOptionUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = ProductOption + success_message = 'Option saved.' + template_name = 'dashboard/option_form.html' + fields = [ + 'name', + 'options', + 'products', + ] + context_object_name = 'option' + success_url = reverse_lazy('dashboard:catalog') + + +class ProductOptionDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = ProductOption + success_message = 'ProductOption deleted.' + template_name = 'dashboard/option_confirm_delete.html' + context_object_name = 'option' + success_url = reverse_lazy('dashboard:catalog') + + class CustomerListView(LoginRequiredMixin, ListView): model = User template_name = 'dashboard/customer_list.html' + paginate_by = 100 def get_queryset(self): object_list = User.objects.filter( @@ -305,7 +550,7 @@ class CustomerListView(LoginRequiredMixin, ListView): 'orders' ).annotate( num_orders=Count('orders') - ) + ).order_by('first_name', 'last_name') return object_list diff --git a/src/fixtures/db.json b/src/fixtures/db.json index f563747..50d5606 100644 --- a/src/fixtures/db.json +++ b/src/fixtures/db.json @@ -1881,7 +1881,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:18:59.584Z", "updated_at": "2022-03-15T17:18:59.584Z" @@ -1897,7 +1897,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:22:18.440Z", "updated_at": "2022-03-15T17:22:18.440Z" @@ -1913,7 +1913,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:26:27.869Z", "updated_at": "2022-03-15T17:26:27.869Z" @@ -1929,7 +1929,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T18:14:16.587Z", "updated_at": "2022-03-15T18:14:16.587Z" @@ -1945,7 +1945,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T18:16:59.460Z", "updated_at": "2022-03-15T18:16:59.460Z" @@ -1961,7 +1961,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T18:23:13.283Z", "updated_at": "2022-03-15T18:23:13.283Z" @@ -1977,7 +1977,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T18:29:02.632Z", "updated_at": "2022-03-15T18:29:02.632Z" @@ -1993,7 +1993,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T19:13:50.050Z", "updated_at": "2022-03-15T19:13:50.050Z" @@ -2009,7 +2009,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T19:15:18.843Z", "updated_at": "2022-03-15T19:15:18.843Z" @@ -2025,7 +2025,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T19:17:21.952Z", "updated_at": "2022-03-15T19:17:21.952Z" @@ -2041,7 +2041,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:22:34.503Z", "updated_at": "2022-03-15T19:22:34.503Z" @@ -2057,7 +2057,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:25:35.313Z", "updated_at": "2022-03-15T19:25:35.313Z" @@ -2073,7 +2073,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:26:51.478Z", "updated_at": "2022-03-15T19:26:51.478Z" @@ -2089,7 +2089,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:30:28.497Z", "updated_at": "2022-03-15T19:30:28.497Z" @@ -2105,7 +2105,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:36:30.561Z", "updated_at": "2022-03-15T19:36:30.561Z" @@ -2121,7 +2121,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:54:38.099Z", "updated_at": "2022-03-15T19:54:38.099Z" @@ -2137,7 +2137,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:56:49.477Z", "updated_at": "2022-03-15T19:56:49.477Z" @@ -2153,7 +2153,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:01:53.848Z", "updated_at": "2022-03-15T20:01:53.848Z" @@ -2169,7 +2169,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:09:31.510Z", "updated_at": "2022-03-15T20:09:31.510Z" @@ -2185,7 +2185,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:13:16.927Z", "updated_at": "2022-03-15T20:13:16.927Z" @@ -2201,7 +2201,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:14:43.333Z", "updated_at": "2022-03-15T20:14:43.333Z" @@ -2217,7 +2217,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:16:03.299Z", "updated_at": "2022-03-15T20:16:03.299Z" @@ -2233,7 +2233,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:17:32.842Z", "updated_at": "2022-03-15T20:17:32.842Z" @@ -2249,7 +2249,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-15T20:21:35.974Z", "updated_at": "2022-03-15T20:21:35.974Z" @@ -2265,7 +2265,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T20:22:11.717Z", "updated_at": "2022-03-15T20:22:11.717Z" @@ -2281,7 +2281,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-03-15T20:23:49.392Z", "updated_at": "2022-03-15T20:23:49.392Z" @@ -2297,7 +2297,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-03-15T20:25:04.787Z", "updated_at": "2022-03-15T20:25:04.787Z" @@ -2313,7 +2313,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-03-15T20:27:47.933Z", "updated_at": "2022-03-15T20:27:47.933Z" @@ -2329,7 +2329,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T20:30:40.141Z", "updated_at": "2022-03-15T20:30:40.141Z" @@ -2345,7 +2345,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T20:32:09.015Z", "updated_at": "2022-03-23T16:02:59.305Z" @@ -2361,7 +2361,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-23T16:59:10.471Z", "updated_at": "2022-03-23T17:00:17.128Z" @@ -2377,7 +2377,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "25.46", + "total_amount": "25.46", "weight": "0.0:oz", "created_at": "2022-03-23T21:22:54.950Z", "updated_at": "2022-03-23T21:22:54.950Z" @@ -2393,7 +2393,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "12.73", + "total_amount": "12.73", "weight": "0.0:oz", "created_at": "2022-03-23T21:30:54.290Z", "updated_at": "2022-03-23T21:30:54.290Z" @@ -2409,7 +2409,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-23T21:45:57.399Z", "updated_at": "2022-03-23T21:45:57.399Z" @@ -2425,7 +2425,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-23T21:52:22.463Z", "updated_at": "2022-03-25T16:51:04.837Z" @@ -2441,7 +2441,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "67.00", + "total_amount": "67.00", "weight": "0.0:oz", "created_at": "2022-04-01T17:09:34.892Z", "updated_at": "2022-04-01T17:09:34.892Z" @@ -2457,7 +2457,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-04T00:02:12.247Z", "updated_at": "2022-04-04T00:02:12.247Z" @@ -2473,7 +2473,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-04T00:03:44.789Z", "updated_at": "2022-04-04T00:03:44.789Z" @@ -2489,7 +2489,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-06T01:18:18.633Z", "updated_at": "2022-04-06T01:18:18.633Z" @@ -2505,7 +2505,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "67.00", + "total_amount": "67.00", "weight": "0.0:oz", "created_at": "2022-04-06T17:48:39.005Z", "updated_at": "2022-04-06T18:04:31.040Z" @@ -2521,7 +2521,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-06T18:00:15.976Z", "updated_at": "2022-04-06T18:00:15.976Z" @@ -2537,7 +2537,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-06T18:01:51.206Z", "updated_at": "2022-04-06T18:01:51.206Z" @@ -2553,7 +2553,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:18:58.958Z", "updated_at": "2022-04-15T03:18:58.958Z" @@ -2569,7 +2569,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:19:14.980Z", "updated_at": "2022-04-15T03:19:14.980Z" @@ -2585,7 +2585,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:21:45.918Z", "updated_at": "2022-04-15T03:21:45.918Z" @@ -2601,7 +2601,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:22:58.009Z", "updated_at": "2022-04-15T03:22:58.009Z" @@ -2617,7 +2617,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:24:22.731Z", "updated_at": "2022-04-15T03:24:22.731Z" @@ -2633,7 +2633,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:24:38.585Z", "updated_at": "2022-04-15T03:24:38.585Z" @@ -2649,7 +2649,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:26:19.552Z", "updated_at": "2022-04-15T03:26:19.552Z" @@ -2665,7 +2665,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-23T20:51:39.679Z", "updated_at": "2022-04-23T20:51:39.679Z" @@ -2681,7 +2681,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-23T20:55:39.285Z", "updated_at": "2022-04-23T20:55:39.285Z" @@ -2697,7 +2697,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-23T21:00:39.249Z", "updated_at": "2022-04-24T03:38:54.039Z" @@ -2713,7 +2713,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:34:28.911Z", "updated_at": "2022-04-24T16:34:28.911Z" @@ -2729,7 +2729,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:37:32.671Z", "updated_at": "2022-04-24T16:37:32.671Z" @@ -2745,7 +2745,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:41:55.368Z", "updated_at": "2022-04-24T16:41:55.368Z" @@ -2761,7 +2761,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:47:43.438Z", "updated_at": "2022-04-24T16:47:43.438Z" @@ -2777,7 +2777,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:49:10.526Z", "updated_at": "2022-04-24T16:49:10.526Z" @@ -2793,7 +2793,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:49:18.644Z", "updated_at": "2022-04-24T16:49:18.645Z" @@ -2809,7 +2809,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:01:14.133Z", "updated_at": "2022-04-24T17:01:14.133Z" @@ -2825,7 +2825,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:03:50.880Z", "updated_at": "2022-04-24T17:03:50.880Z" @@ -2841,7 +2841,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:19:22.528Z", "updated_at": "2022-04-24T17:19:22.528Z" @@ -2857,7 +2857,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:23:48.946Z", "updated_at": "2022-04-24T17:23:48.946Z" @@ -2873,7 +2873,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:35:04.209Z", "updated_at": "2022-04-24T17:35:04.209Z" @@ -2889,7 +2889,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:35:40.334Z", "updated_at": "2022-04-24T17:35:40.334Z" @@ -2905,7 +2905,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:36:27.559Z", "updated_at": "2022-04-24T17:36:46.155Z" @@ -2921,7 +2921,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T17:52:07.802Z", "updated_at": "2022-04-24T17:52:07.802Z" @@ -2937,7 +2937,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "12.47", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:52:59.926Z", "updated_at": "2022-04-24T17:53:38.188Z" @@ -2953,7 +2953,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T17:57:18.399Z", "updated_at": "2022-04-24T17:57:18.399Z" @@ -2969,7 +2969,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T18:36:43.689Z", "updated_at": "2022-04-24T18:37:06.954Z" @@ -2985,7 +2985,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "0.00", + "total_amount": "0.00", "weight": "0.0:oz", "created_at": "2022-04-24T20:44:10.464Z", "updated_at": "2022-04-24T20:44:10.464Z" @@ -3001,7 +3001,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T20:44:28.234Z", "updated_at": "2022-04-24T20:44:44.522Z" @@ -3017,7 +3017,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T21:06:59.696Z", "updated_at": "2022-04-24T21:07:17.313Z" diff --git a/src/ptcoffee/config.py b/src/ptcoffee/config.py index 74e6660..8701bbc 100644 --- a/src/ptcoffee/config.py +++ b/src/ptcoffee/config.py @@ -23,6 +23,8 @@ SENTRY_ENV = os.environ.get('SENTRY_ENV', 'development') FACEBOOK_PIXEL_ID = os.environ.get('FACEBOOK_PIXEL_ID', '') +STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', '') + PAYPAL_CLIENT_ID = os.environ.get('PAYPAL_CLIENT_ID', '') PAYPAL_SECRET_ID = os.environ.get('PAYPAL_SECRET_ID', '') PAYPAL_ENVIRONMENT = os.environ.get('PAYPAL_ENVIRONMENT', 'SANDBOX') diff --git a/src/ptcoffee/settings.py b/src/ptcoffee/settings.py index c8fd473..39b2ebf 100644 --- a/src/ptcoffee/settings.py +++ b/src/ptcoffee/settings.py @@ -85,7 +85,9 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'core.context_processors.site_settings', 'storefront.context_processors.cart', + 'storefront.context_processors.product_categories', ], }, }, @@ -256,17 +258,19 @@ CELERY_TASK_TRACK_STARTED = True CELERY_TIMEZONE = 'US/Mountain' # Sentry -sentry_sdk.init( - dsn=SENTRY_DSN, - environment=SENTRY_ENV, - integrations=[DjangoIntegration()], - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - # We recommend adjusting this value in production. - traces_sample_rate=1.0, +if not DEBUG: + sentry_sdk.init( + dsn=SENTRY_DSN, + environment=SENTRY_ENV, + integrations=[DjangoIntegration()], - # If you wish to associate users to errors (assuming you are using - # django.contrib.auth) you may enable sending PII data. - send_default_pii=True -) + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production. + traces_sample_rate=1.0, + + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True + ) diff --git a/src/ptcoffee/urls.py b/src/ptcoffee/urls.py index 601e045..1ea5d01 100644 --- a/src/ptcoffee/urls.py +++ b/src/ptcoffee/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('dashboard/', include(('dashboard.urls', 'dashboard'), namespace='dashboard')), path('accounts/', include('allauth.urls')), path('accounts/', include(('accounts.urls', 'accounts'), namespace='accounts')), + # path('accounts/', include('django.contrib.auth.urls')), path('admin/', admin.site.urls), path('captcha/', include('captcha.urls')), diff --git a/src/static/images/warehouse.png b/src/static/images/warehouse.png new file mode 100644 index 0000000..bbede28 Binary files /dev/null and b/src/static/images/warehouse.png differ diff --git a/src/static/scripts/checkout.js b/src/static/scripts/checkout.js new file mode 100644 index 0000000..e75de16 --- /dev/null +++ b/src/static/scripts/checkout.js @@ -0,0 +1,114 @@ +// This is your test publishable API key. +const stripe = Stripe('pk_test_WuL7S3g73f4j9Y69pMF10r3k00G5IPCCSc') + +// The items the customer wants to buy +const items = [{ id: 'xl-tshirt' }] + +let elements + +initialize() +checkStatus() + +document + .querySelector('#payment-form') + .addEventListener('submit', handleSubmit) + +// Fetches a payment intent and captures the client secret +async function initialize () { + const response = await fetch('/create-payment-intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items }) + }) + const { clientSecret } = await response.json() + + const appearance = { + theme: 'stripe' + } + elements = stripe.elements({ appearance, clientSecret }) + + const paymentElement = elements.create('payment') + paymentElement.mount('#payment-element') +} + +async function handleSubmit (e) { + e.preventDefault() + setLoading(true) + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + // Make sure to change this to your payment completion page + return_url: 'http://localhost:4242/checkout.html' + } + }) + + // This point will only be reached if there is an immediate error when + // confirming the payment. Otherwise, your customer will be redirected to + // your `return_url`. For some payment methods like iDEAL, your customer will + // be redirected to an intermediate site first to authorize the payment, then + // redirected to the `return_url`. + if (error.type === 'card_error' || error.type === 'validation_error') { + showMessage(error.message) + } else { + showMessage('An unexpected error occurred.') + } + + setLoading(false) +} + +// Fetches the payment intent status after payment submission +async function checkStatus () { + const clientSecret = new URLSearchParams(window.location.search).get( + 'payment_intent_client_secret' + ) + + if (!clientSecret) { + return + } + + const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret) + + switch (paymentIntent.status) { + case 'succeeded': + showMessage('Payment succeeded!') + break + case 'processing': + showMessage('Your payment is processing.') + break + case 'requires_payment_method': + showMessage('Your payment was not successful, please try again.') + break + default: + showMessage('Something went wrong.') + break + } +} + +// ------- UI helpers ------- + +function showMessage (messageText) { + const messageContainer = document.querySelector('#payment-message') + + messageContainer.classList.remove('hidden') + messageContainer.textContent = messageText + + setTimeout(function () { + messageContainer.classList.add('hidden') + messageText.textContent = '' + }, 4000) +} + +// Show a spinner on payment submission +function setLoading (isLoading) { + if (isLoading) { + // Disable the button and show a spinner + document.querySelector('#submit').disabled = true + document.querySelector('#spinner').classList.remove('hidden') + document.querySelector('#button-text').classList.add('hidden') + } else { + document.querySelector('#submit').disabled = false + document.querySelector('#spinner').classList.add('hidden') + document.querySelector('#button-text').classList.remove('hidden') + } +} diff --git a/src/static/scripts/subscriptions.js b/src/static/scripts/subscriptions.js new file mode 100644 index 0000000..61e94f4 --- /dev/null +++ b/src/static/scripts/subscriptions.js @@ -0,0 +1,128 @@ +class Subscription { + static TWELVE_OZ + static SIXTEEN_OZ + static FIVE_LBS + + static TWELVE_SHIPPING + static SIXTEEN_SHIPPING + static FIVE_SHIPPING + + constructor(element, output) { + this.TWELVE_OZ = '12' + this.SIXTEEN_OZ = '16' + this.FIVE_LBS = '75' + this.TWELVE_SHIPPING = 7 + this.SIXTEEN_SHIPPING = 5 + this.FIVE_SHIPPING = 1 + + this.element = element + this.output = this.element.querySelector('.output') + this.shippingDiscount = 10 + this.price = this.element.querySelector('select[name=size]') + this.products = this.element.querySelectorAll('input[name^=product]') + this.element.addEventListener('change', this.render.bind(this)) + this.render() + } + + get total_qty() { + return Array.from(this.products).reduce((total, current) => { + return total + Number(current.value) + }, 0) + } + + get hasFreeShipping() { + switch(this.price.value) { + case this.TWELVE_OZ: + if (parseInt(this.total_qty) >= this.TWELVE_SHIPPING) { + return true + } else { + return false + } + break + case this.SIXTEEN_OZ: + if (parseInt(this.total_qty) >= this.SIXTEEN_SHIPPING) { + return true + } else { + return false + } + break + case this.FIVE_LBS: + if (parseInt(this.total_qty) >= this.FIVE_SHIPPING) { + return true + } else { + return false + } + break + default: + throw 'Something is wrong with the price' + } + } + + get countToFreeShipping() { + switch(this.price.value) { + case this.TWELVE_OZ: + return this.TWELVE_SHIPPING - this.total_qty + break + case this.SIXTEEN_OZ: + return this.SIXTEEN_SHIPPING - this.total_qty + break + case this.FIVE_LBS: + return this.FIVE_SHIPPING + break + default: + throw 'Something is wrong with the price' + break + } + } + + get shippingStatus() { + let items = 0 + + if (this.hasFreeShipping) { + return 'You have free shipping!' + } else { + return `Add ${this.countToFreeShipping} more item(s) for free shipping!` + } + + } + + get totalRetailPrice() { + let totalPrice = Array.from(this.products).reduce((total, current) => { + return total + (Number(this.price.value) * current.value); + }, 0); + + return new Intl.NumberFormat('en-US', { + currency: 'USD', + style: 'currency', + }).format(totalPrice) + } + + get totalPrice() { + let totalPrice = Array.from(this.products).reduce((total, current) => { + return total + (Number(this.price.value) * current.value); + }, 0); + + let percentage = (this.shippingDiscount / 100) * totalPrice + + + return new Intl.NumberFormat('en-US', { + currency: 'USD', + style: 'currency', + }).format(totalPrice - percentage) + } + + render() { + this.output.querySelector('.retail-price').innerText = this.totalRetailPrice + this.output.querySelector('.price').innerText = this.totalPrice + this.output.querySelector('.shipping').innerText = this.shippingStatus + } + + add_item(item) { + this.items.push(item) + return this.items + } +} + + +const subCreateFromEl = document.querySelector('.subscription-create-form') +const sub = new Subscription(subCreateFromEl) diff --git a/src/static/styles/checkout.css b/src/static/styles/checkout.css new file mode 100644 index 0000000..70d86b5 --- /dev/null +++ b/src/static/styles/checkout.css @@ -0,0 +1,77 @@ + +/* spinner/processing state, errors */ +.spinner, +.spinner:before, +.spinner:after { + border-radius: 50%; +} +.spinner { + color: #ffffff; + font-size: 22px; + text-indent: -99999px; + margin: 0px auto; + position: relative; + width: 20px; + height: 20px; + box-shadow: inset 0 0 0 2px; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); +} +.spinner:before, +.spinner:after { + position: absolute; + content: ""; +} +.spinner:before { + width: 10.4px; + height: 20.4px; + background: #5469d4; + border-radius: 20.4px 0 0 20.4px; + top: -0.2px; + left: -0.2px; + -webkit-transform-origin: 10.4px 10.2px; + transform-origin: 10.4px 10.2px; + -webkit-animation: loading 2s infinite ease 1.5s; + animation: loading 2s infinite ease 1.5s; +} +.spinner:after { + width: 10.4px; + height: 10.2px; + background: #5469d4; + border-radius: 0 10.2px 10.2px 0; + top: -0.1px; + left: 10.2px; + -webkit-transform-origin: 0px 10.2px; + transform-origin: 0px 10.2px; + -webkit-animation: loading 2s infinite ease; + animation: loading 2s infinite ease; +} + +@-webkit-keyframes loading { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes loading { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@media only screen and (max-width: 600px) { + form { + width: 80vw; + min-width: initial; + } +} diff --git a/src/static/styles/dashboard.css b/src/static/styles/dashboard.css index 239348e..672105f 100644 --- a/src/static/styles/dashboard.css +++ b/src/static/styles/dashboard.css @@ -367,7 +367,7 @@ main article { } .product__figure img { - max-height: 400px; + max-height: 200px; } diff --git a/src/static/styles/main.css b/src/static/styles/main.css index be9d5f3..937ad1d 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -526,6 +526,22 @@ section:not(:last-child) { } +/* Breadcrumbs + ========================================================================== */ +.breadcrumbs { + margin-bottom: 1.5rem; +} +.breadcrumbs menu { + margin: 0; + padding: 0 1rem; + line-height: 1.75; + list-style: none; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + + /* ========================================================================== Articles ========================================================================== */ @@ -639,6 +655,28 @@ article + article { } +.subscription-create-form { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2rem; +} + +.product__subscription-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + justify-items: center; + gap: 6rem; + overflow-y: scroll; + max-height: 50rem; + border-bottom: var(--default-border); + padding: 0 2rem 2rem; +} + +.product__subscription-list div { + /*max-width: 10rem;*/ +} + + /* Product Detail ========================================================================== */ .product__detail { @@ -751,6 +789,7 @@ article + article { .item__price { justify-self: end; + text-align: right; } .item__form, @@ -889,3 +928,8 @@ footer > section { text-align: center; } + + +.show-modal { + white-space: unset; +} diff --git a/src/storefront/cart.py b/src/storefront/cart.py index c783e96..ff0c03e 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -6,99 +6,131 @@ from django.conf import settings from django.contrib import messages from django.shortcuts import redirect, reverse from django.urls import reverse_lazy +from django.db.models import OuterRef, Q, Subquery -from core.models import Product, OrderLine, Coupon +from core.models import ( + Product, ProductVariant, OrderLine, Coupon, ShippingRate +) from core.usps import USPSApi from core import ( DiscountValueType, VoucherType, TransactionStatus, OrderStatus, - ShippingMethodType, ShippingService, ShippingContainer, - CoffeeGrind + CoffeeGrind, + build_usps_rate_request ) +from .forms import UpdateCartItemForm from .payments import CreateOrder logger = logging.getLogger(__name__) +class CartItem: + update_form = UpdateCartItemForm + + def __init__(self, item): + self.variant = item['variant'] + self.quantity = item['quantity'] + self.options = item['options'] + + def get_update_form(self, index): + return self.update_form(initial={ + 'item_pk': index, + 'quantity': self.quantity + }) + + def __iter__(self): + yield ('name', str(self.variant)) + yield ('description', self.variant.product.subtitle) + yield ('unit_amount', { + 'currency_code': settings.DEFAULT_CURRENCY, + 'value': f'{self.variant.price}', + }) + yield ('quantity', f'{item["quantity"]}') + + def __str__(self): + return str(self.variant) + + class Cart: + item_class = CartItem + def __init__(self, request): self.request = request self.session = request.session self.coupon_code = self.session.get('coupon_code') - self.container = self.session.get('shipping_container') cart = self.session.get(settings.CART_SESSION_ID) if not cart: - cart = self.session[settings.CART_SESSION_ID] = {} + cart = self.session[settings.CART_SESSION_ID] = [] self.cart = cart - def add( - self, request, product, quantity=1, grind='', update_quantity=False - ): - product_id = str(product.id) - if product_id not in self.cart: - self.cart[product_id] = { - 'variations': {}, - 'price': str(product.price) - } - self.cart[product_id]['variations'][grind] = {'quantity': 0} - + def add(self, request, item, update_quantity=False): if update_quantity: - self.cart[product_id]['variations'][grind]['quantity'] = quantity + self.cart[item['variant']]['quantity'] = item['quantity'] else: - if not grind in self.cart[product_id]['variations']: - # create it - self.cart[product_id]['variations'][grind] = {'quantity': quantity} - else: - # add to it - self.cart[product_id]['variations'][grind]['quantity'] += quantity + self.add_or_update_item(item) + + # TODO: abstract this to a function that will check the max amount of item in the cart if len(self) <= 20: + self.check_item_stock_quantities(request) self.save() else: messages.warning(request, "Cart is full: 20 items or less.") + def add_or_update_item(self, new_item): + new_item_pk = int(new_item['variant']) + for item in self: + if new_item_pk == item['variant'].pk: + if new_item['options'] == item['options']: + item['quantity'] += new_item['quantity'] + return + else: + continue + self.cart.append(new_item) + def save(self): self.session[settings.CART_SESSION_ID] = self.cart self.session.modified = True logger.info(f'\nCart:\n{self.cart}\n') - def remove(self, product, grind): - product_id = str(product.id) - if product_id in self.cart: - del self.cart[product_id]['variations'][grind] - if not self.cart[product_id]['variations']: - del self.cart[product_id] - self.save() + def check_item_stock_quantities(self, request): + for item in self: + if item['variant'].track_inventory: + if item['quantity'] > item['variant'].stock: + messages.warning(request, 'Quantity added exceeds available stock.') + item['quantity'] = item['variant'].stock + self.save() + + def remove(self, pk): + self.cart.pop(pk) + self.save() def __iter__(self): - product_ids = self.cart.keys() - products = Product.objects.filter(id__in=product_ids) - for product in products: - self.cart[str(product.id)]['product'] = product - - for item in self.cart.values(): - item['price'] = Decimal(item['price']) - item['total_price'] = Decimal(sum(self.get_item_prices())) - item['quantity'] = self.get_single_item_total_quantity(item) + for item in self.cart: + pk = item['variant'].pk if isinstance(item['variant'], ProductVariant) else item['variant'] + item['variant'] = ProductVariant.objects.get(pk=pk) + item['price_total'] = item['variant'].price * item['quantity'] yield item def __len__(self): - return sum(self.get_all_item_quantities()) + return sum([item['quantity'] for item in self.cart]) def get_all_item_quantities(self): - for item in self.cart.values(): - yield sum([value['quantity'] for value in item['variations'].values()]) + for item in self.cart: + yield item['quantity'] def get_single_item_total_quantity(self, item): return sum([value['quantity'] for value in item['variations'].values()]) def get_item_prices(self): - for item in self.cart.values(): - yield Decimal(item['price']) * sum([value['quantity'] for value in item['variations'].values()]) + for item in self: + yield item['price_total'] + # for item in self.cart.values(): + # yield Decimal(item['price']) * sum([value['quantity'] for value in item['variations'].values()]) def get_total_price(self): return sum(self.get_item_prices()) @@ -106,32 +138,38 @@ class Cart: def get_total_weight(self): if len(self) > 0: for item in self: - return item['product'].weight.value * sum(self.get_all_item_quantities()) + return item['variant'].weight.value * sum(self.get_all_item_quantities()) else: return 0 - def get_shipping_box(self, container=None): - if container: - return container - - if self.container: - return self.container - - if len(self) > 6 and len(self) <= 10: - return ShippingContainer.LG_FLAT_RATE_BOX - elif len(self) > 3 and len(self) <= 6: - return ShippingContainer.REGIONAL_RATE_BOX_B - elif len(self) <= 3: - return ShippingContainer.REGIONAL_RATE_BOX_A - else: - return ShippingContainer.VARIABLE + def get_shipping_container_choices(self): + is_selectable = Q( + is_selectable=True + ) + min_weight_matched = Q( + min_order_weight__lte=self.get_total_weight()) | Q( + min_order_weight__isnull=True + ) + max_weight_matched = Q( + max_order_weight__gte=self.get_total_weight()) | Q( + max_order_weight__isnull=True + ) + containers = ShippingRate.objects.filter( + is_selectable & min_weight_matched & max_weight_matched + ) + return containers def get_shipping_cost(self, container=None): - if len(self) > 0 and self.session.get("shipping_address"): - try: - usps_rate_request = self.build_usps_rate_request(container) - except TypeError as e: - return Decimal('0.00') + if container is None: + container = self.session.get('shipping_container').container + + if len(self) > 0 and self.session.get('shipping_address'): + usps_rate_request = build_usps_rate_request( + str(self.get_total_weight()), + container, + str(self.session.get('shipping_address')['postal_code']) + ) + usps = USPSApi(settings.USPS_USER_ID, test=True) try: @@ -142,10 +180,11 @@ class Cart: ) logger.info(validation.result) - if 'Error' not in validation.result['RateV4Response']['Package']: - rate = validation.result['RateV4Response']['Package']['Postage']['CommercialRate'] + package = dict(validation.result['RateV4Response']['Package']) + if 'Error' not in package: + rate = package['Postage']['CommercialRate'] else: - logger.error("USPS Rate error") + logger.error('USPS Rate error') rate = '0.00' return Decimal(rate) else: @@ -159,22 +198,6 @@ class Cart: pass self.session.modified = True - def build_usps_rate_request(self, container=None): - return \ - { - 'service': ShippingService.PRIORITY_COMMERCIAL, - 'zip_origination': settings.DEFAULT_ZIP_ORIGINATION, - 'zip_destination': f'{self.session.get("shipping_address")["postal_code"]}', - 'pounds': '0', - 'ounces': f'{self.get_total_weight()}', - 'container': f'{self.get_shipping_box(container)}', - 'width': '', - 'length': '', - 'height': '', - 'girth': '', - 'machinable': 'TRUE' - } - def build_order_params(self, container=None): return \ { @@ -187,7 +210,9 @@ class Cart: 'shipping_method': 'US POSTAL SERVICE ' + ( container if container else '' ), - 'shipping_address': self.build_shipping_address(self.session.get('shipping_address')), + 'shipping_address': self.build_shipping_address( + self.session.get('shipping_address') + ), } def create_order(self, container=None): @@ -199,20 +224,22 @@ class Cart: response = CreateOrder().create_order(params) return response + def get_line_options(self, options_dict): + options = '' + for key, value in options_dict.items(): + options += f'{key}: {value}; ' + return options + def build_bulk_list(self, order): bulk_list = [] - for item in self: - for key, value in item['variations'].items(): - bulk_list.append(OrderLine( - order=order, - product=item['product'], - customer_note=next((v[1] for i, v in enumerate(CoffeeGrind.GRIND_CHOICES) if v[0] == key), None), - unit_price=item['price'], - quantity=value['quantity'], - tax_rate=2, - )) - + bulk_list.append(OrderLine( + order=order, + variant=item['variant'], + customer_note=self.get_line_options(item['options']), + unit_price=item['variant'].price, + quantity=item['quantity'] + )) return bulk_list def build_shipping_address(self, address): @@ -232,12 +259,25 @@ class Cart: return Coupon.objects.get(code=self.coupon_code) return None + def get_coupon_total_for_specific_products(self): + for item in self.cart: + if item['variant'].product in self.coupon.products.all(): + yield item['price_total'] + def get_discount(self): + # SHIPPING + # ENTIRE_ORDER + # SPECIFIC_PRODUCT if self.coupon: if self.coupon.discount_value_type == DiscountValueType.FIXED: return round(self.coupon.discount_value, 2) elif self.coupon.discount_value_type == DiscountValueType.PERCENTAGE: - return round((self.coupon.discount_value / Decimal('100')) * self.get_total_price(), 2) + if self.coupon.type == VoucherType.ENTIRE_ORDER: + return round((self.coupon.discount_value / Decimal('100')) * self.get_total_price(), 2) + elif self.coupon.type == VoucherType.SPECIFIC_PRODUCT: + # Get the product in cart quantity + total = sum(self.get_coupon_total_for_specific_products()) + return round((self.coupon.discount_value / Decimal('100')) * total, 2) return Decimal('0') def get_subtotal_price_after_discount(self): diff --git a/src/storefront/context_processors.py b/src/storefront/context_processors.py index f29d3e2..7ae0783 100644 --- a/src/storefront/context_processors.py +++ b/src/storefront/context_processors.py @@ -1,6 +1,14 @@ +from core.models import ProductCategory from .cart import Cart + def cart(request): return { 'cart': Cart(request) } + + +def product_categories(self): + return { + 'category_list': ProductCategory.objects.all() + } diff --git a/src/storefront/forms.py b/src/storefront/forms.py index 5a59231..fbe7a1c 100644 --- a/src/storefront/forms.py +++ b/src/storefront/forms.py @@ -1,5 +1,6 @@ import logging import json +import stripe from requests import ConnectionError from urllib.parse import quote from django import forms @@ -10,23 +11,36 @@ from localflavor.us.us_states import USPS_CHOICES from usps import USPSApi, Address from captcha.fields import CaptchaField -from core.models import Order +from core.models import Order, ProductVariant from core import CoffeeGrind, ShippingContainer logger = logging.getLogger(__name__) class AddToCartForm(forms.Form): - grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES) quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) + def __init__(self, variants, options, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['variant'] = forms.ChoiceField( + label='', + choices=[(variant.pk, f'{variant.name} | ${variant.price}') for variant in variants] + ) + + for option in options: + self.fields[option.name] = forms.ChoiceField( + choices=[(opt, opt) for opt in option.options] + ) + class UpdateCartItemForm(forms.Form): + item_pk = forms.IntegerField(widget=forms.HiddenInput()) quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) update = forms.BooleanField( required=False, initial=True, - widget=forms.HiddenInput + widget=forms.HiddenInput() ) @@ -105,15 +119,14 @@ class AddressForm(forms.Form): class CheckoutShippingForm(forms.Form): - SHIPPING_CHOICES = [ - (ShippingContainer.MD_FLAT_RATE_BOX, 'Flate Rate Box - Medium'), - (ShippingContainer.REGIONAL_RATE_BOX_B, 'Regional Rate Box B'), - ] + def __init__(self, containers, *args, **kwargs): + super().__init__(*args, **kwargs) - shipping_method = forms.ChoiceField( - widget=forms.RadioSelect, - choices=SHIPPING_CHOICES - ) + self.fields['shipping_method'] = forms.ChoiceField( + label='', + widget=forms.RadioSelect, + choices=[(container.pk, f'{container.name} ${container.s_cost}') for container in containers] + ) class OrderCreateForm(forms.ModelForm): @@ -124,14 +137,66 @@ class OrderCreateForm(forms.ModelForm): class Meta: model = Order fields = ( - 'total_net_amount', + 'total_amount', 'shipping_total', ) widgets = { - 'total_net_amount': forms.HiddenInput(), + 'total_amount': forms.HiddenInput(), 'shipping_total': forms.HiddenInput() } class CouponApplyForm(forms.Form): code = forms.CharField(label='Coupon code') + + +class ContactForm(forms.Form): + GOOGLE = 'Google Search' + SHOP = 'The coffee shop' + WOM = 'Word of mouth' + PRODUCT = 'Coffee Bag' + STORE = 'Store' + OTHER = 'Other' + + REFERAL_CHOICES = [ + (GOOGLE, 'Google Search'), + (SHOP, '"Better Living Through Coffee" coffee shop'), + (WOM, 'Friend/Relative'), + (PRODUCT, 'Our Coffee Bag'), + (STORE, 'PT Food Coop/other store'), + (OTHER, 'Other (please describe below)'), + ] + + full_name = forms.CharField() + email_address = forms.EmailField() + referal = forms.ChoiceField( + label='How did you find our website?', + choices=REFERAL_CHOICES + ) + subject = forms.CharField() + message = forms.CharField(widget=forms.Textarea) + captcha = CaptchaField() + + +class SubscriptionCreateForm(forms.Form): + SEVEN_DAYS = 7 + FOURTEEN_DAYS = 14 + THIRTY_DAYS = 30 + SCHEDULE_CHOICES = [ + (SEVEN_DAYS, 'Every 7 days'), + (FOURTEEN_DAYS, 'Every 14 days'), + (THIRTY_DAYS, 'Every 30 days'), + ] + + TWELVE_OZ = 12 + SIXTEEN_OZ = 16 + FIVE_LBS = 75 + SIZE_CHOICES = [ + (TWELVE_OZ, '12 oz ($10.80)'), + (SIXTEEN_OZ, '16 oz ($14.40)'), + (FIVE_LBS, '5 lbs ($67.50)'), + ] + + grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES) + schedule = forms.ChoiceField(choices=SCHEDULE_CHOICES) + size = forms.ChoiceField(choices=SIZE_CHOICES) diff --git a/src/storefront/payments.py b/src/storefront/payments.py index 1d806ae..c55bdf8 100644 --- a/src/storefront/payments.py +++ b/src/storefront/payments.py @@ -93,21 +93,13 @@ class CreateOrder(PayPalClient): processed_items = [ { # Shows within upper-right dropdown during payment approval - "name": f'{item["product"]}: ' + ', '.join([ - next(( - f"{value['quantity']} x {v[1]}" - for i, v in enumerate(CoffeeGrind.GRIND_CHOICES) - if v[0] == key - ), - None, - ) for key, value in item["variations"].items()] - )[:100], + "name": str(item["variant"]), # Item details will also be in the completed paypal.com # transaction view - "description": item["product"].subtitle, + "description": item["variant"].product.subtitle, "unit_amount": { "currency_code": settings.DEFAULT_CURRENCY, - "value": f'{item["price"]}', + "value": f'{item["variant"].price}', }, "quantity": f'{item["quantity"]}', } diff --git a/src/storefront/templates/storefront/cart_detail.html b/src/storefront/templates/storefront/cart_detail.html index 418d1dd..9ff2cc8 100644 --- a/src/storefront/templates/storefront/cart_detail.html +++ b/src/storefront/templates/storefront/cart_detail.html @@ -11,26 +11,30 @@
{% for item in cart %}
- {% with product=item.product %} + {% with product=item.variant.product %}
{{product.get_first_img.image}}

{{product.name}}

-
Grind:
- {% for key, value in item.variations.items %} -

{{ key|get_grind_display }}
-

- {% csrf_token %} - {{ value.update_quantity_form }} - - Remove item -
-

+

{{ item.variant.name }}

+ {% for key, value in item.options.items %} +

{{ key }}: {{ value }}

{% endfor %} +
+ {% csrf_token %} + {{ item.update_quantity_form }} + +
+

Remove item

-

${{item.price}}

+

+ ${{ item.variant.price }} + {% if cart.coupon and cart.coupon.type == 'specific_product' and product in cart.coupon.products.all %} +
Coupon: {{ cart.coupon.name }} ({{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}) + {% endif %} +

{% endwith %}
@@ -56,7 +60,7 @@ Subtotal ${{ cart.get_total_price|floatformat:"2" }} - {% if cart.coupon %} + {% if cart.coupon and cart.coupon.type == 'entire_order' %} Coupon {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}} diff --git a/src/storefront/templates/storefront/category_detail.html b/src/storefront/templates/storefront/category_detail.html new file mode 100644 index 0000000..c19c4fb --- /dev/null +++ b/src/storefront/templates/storefront/category_detail.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Welcome to our new website!

+

NEW COOL LOOK, SAME GREAT COFFEE

+
+ +{% endblock %} + diff --git a/src/storefront/templates/storefront/checkout_shipping_form.html b/src/storefront/templates/storefront/checkout_shipping_form.html index cd71ba3..e2d9c66 100644 --- a/src/storefront/templates/storefront/checkout_shipping_form.html +++ b/src/storefront/templates/storefront/checkout_shipping_form.html @@ -14,20 +14,7 @@ {% csrf_token %} {{ form.non_field_errors }}
- {{ form.shipping_method.label }} - {% for radio in form.shipping_method %} -

- - {{ radio.tag }} -

- {% endfor %} + {{form.as_p}}

diff --git a/src/storefront/templates/storefront/order_detail.html b/src/storefront/templates/storefront/order_detail.html index 7c0d48c..3ee8ba9 100644 --- a/src/storefront/templates/storefront/order_detail.html +++ b/src/storefront/templates/storefront/order_detail.html @@ -21,12 +21,12 @@ {% for item in order.lines.all %} - {% with product=item.product %} + {% with product=item.variant.product %} {{product.get_first_img.image}} - {{product.name}}
+ {{ item.variant }}
{{item.customer_note}} {{item.quantity}} @@ -48,7 +48,7 @@ - + {% if order.coupon %} @@ -62,7 +62,7 @@ - +
Subtotal${{order.total_net_amount}}${{order.subtotal}}
Total${{order.get_total_price_after_discount}}${{order.total_amount}}

diff --git a/src/storefront/templates/storefront/order_form.html b/src/storefront/templates/storefront/order_form.html index 3e50ac8..8614c54 100644 --- a/src/storefront/templates/storefront/order_form.html +++ b/src/storefront/templates/storefront/order_form.html @@ -32,18 +32,24 @@

Review items

{% for item in cart %}
- {% with product=item.product %} + {% with product=item.variant.product %}
{{product.get_first_img.image}}
-

{{product.name}}

- {% for key, value in item.variations.items %} -

Grind: {{ key|get_grind_display }}, Qty: {{value.quantity}}

+

{{product.name}}

+

{{ item.variant.name }}

+ {% for key, value in item.options.items %} +

{{ key }}: {{ value }}

{% endfor %}
-

${{item.price}}

+

+ {{ item.quantity }} × ${{ item.variant.price }} + {% if cart.coupon and cart.coupon.type == 'specific_product' and product in cart.coupon.products.all %} +
Coupon: {{ cart.coupon.name }} ({{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}) + {% endif %} +

{% endwith %}
@@ -61,7 +67,7 @@ Subtotal ${{cart.get_total_price|floatformat:"2"}} - {% if cart.coupon %} + {% if cart.coupon and cart.coupon.type == 'entire_order' %} Coupon {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}} diff --git a/src/storefront/templates/storefront/product_detail.html b/src/storefront/templates/storefront/product_detail.html index 3594102..d88686f 100644 --- a/src/storefront/templates/storefront/product_detail.html +++ b/src/storefront/templates/storefront/product_detail.html @@ -21,9 +21,6 @@

{{product.name}}

{{product.subtitle}}

{{product.description}}

-

Fair trade

-

${{product.price}}

-

{{product.weight.oz|floatformat}}oz

{% csrf_token %} {{ form.as_p }} diff --git a/src/storefront/templates/storefront/product_list.html b/src/storefront/templates/storefront/product_list.html index e0eb0c5..ae9b85f 100644 --- a/src/storefront/templates/storefront/product_list.html +++ b/src/storefront/templates/storefront/product_list.html @@ -5,6 +5,14 @@ {% endblock %} +{% block product_categories %} + +{% endblock product_categories %} + {% block content %}

Welcome to our new website!

@@ -21,7 +29,7 @@

{{ product.name }}

{{ product.subtitle }}

{{product.description|truncatewords:20}}

-

${{product.price}} | {{product.weight.oz|floatformat}}oz

+

${{product.variants.first.price}}

{% endfor %} diff --git a/src/storefront/templates/storefront/subscriptions.html b/src/storefront/templates/storefront/subscriptions.html new file mode 100644 index 0000000..a1d034e --- /dev/null +++ b/src/storefront/templates/storefront/subscriptions.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Subscriptions

+

SUBSCRIBE AND SAVE

+
+
+
+ + {% csrf_token %} +
+

Pick your coffee

+
+ {% for product in product_list %} +
+ + + +
+ {% endfor %} +
+
+
+

Pick your options

+ {{ form.as_p }} +
+ + + + + + + + + + + + + +
Retail total
Save10%
Subscription total
+
+

+

+ +

+
+ +
+
+{% endblock %} diff --git a/src/storefront/tests/test_cart.py b/src/storefront/tests/test_cart.py index 5ea93de..2aecbd1 100644 --- a/src/storefront/tests/test_cart.py +++ b/src/storefront/tests/test_cart.py @@ -35,7 +35,7 @@ class CartTest(TestCase): ) cls.order = Order.objects.create( customer=cls.customer, - total_net_amount=13.4 + total_amount=13.4 ) def setUp(self): diff --git a/src/storefront/tests/test_views.py b/src/storefront/tests/test_views.py index dbade8b..a680c05 100644 --- a/src/storefront/tests/test_views.py +++ b/src/storefront/tests/test_views.py @@ -78,7 +78,7 @@ class OrderCreateViewTest(TestCase): ) cls.order = Order.objects.create( customer=cls.customer, - total_net_amount=13.4 + total_amount=13.4 ) def setUp(self): diff --git a/src/storefront/urls.py b/src/storefront/urls.py index 3c13553..3512184 100644 --- a/src/storefront/urls.py +++ b/src/storefront/urls.py @@ -5,7 +5,18 @@ urlpatterns = [ path('about/', views.AboutView.as_view(), name='about'), path('fair-trade/', views.FairTradeView.as_view(), name='fair-trade'), path('reviews/', views.ReviewListView.as_view(), name='reviews'), + path('contact/', views.ContactFormView.as_view(), name='contact'), + path( + 'subscriptions/', + views.SubscriptionCreateView.as_view(), + name='subscriptions' + ), + path( + 'categories//', + views.ProductCategoryDetailView.as_view(), + name='category-detail' + ), path('', views.ProductListView.as_view(), name='product-list'), path('products//', include([ path('', views.ProductDetailView.as_view(), name='product-detail'), @@ -18,12 +29,12 @@ urlpatterns = [ name='cart-add' ), path( - 'cart//update//', + 'cart//update/', views.CartUpdateProductView.as_view(), name='cart-update', ), path( - 'cart//remove//', + 'cart//remove/', views.cart_remove_product_view, name='cart-remove', ), @@ -37,11 +48,6 @@ urlpatterns = [ views.paypal_order_transaction_capture, name='paypal-capture', ), - path( - 'paypal/webhooks/', - views.paypal_webhook_endpoint, - name='paypal-webhook' - ), path( 'checkout/address/', views.CheckoutAddressView.as_view(), @@ -52,8 +58,16 @@ urlpatterns = [ views.CheckoutShippingView.as_view(), name='checkout-shipping', ), - path('checkout/', views.OrderCreateView.as_view(), name='order-create'), - path('done/', views.PaymentDoneView.as_view(), name='payment-done'), + path( + 'checkout/', + views.OrderCreateView.as_view(), + name='order-create' + ), + path( + 'done/', + views.PaymentDoneView.as_view(), + name='payment-done' + ), path( 'canceled/', views.PaymentCanceledView.as_view(), @@ -90,5 +104,5 @@ urlpatterns = [ views.CustomerAddressUpdateView.as_view(), name='address-update', ) - ])) + ])), ] diff --git a/src/storefront/views.py b/src/storefront/views.py index 541edc7..7e46c41 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -1,14 +1,16 @@ import logging import requests import json +import stripe from django.conf import settings from django.utils import timezone from django.shortcuts import render, reverse, redirect, get_object_or_404 from django.urls import reverse_lazy from django.core.mail import EmailMessage +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.http import JsonResponse, HttpResponseRedirect -from django.views.generic.base import RedirectView, TemplateView +from django.views.generic.base import View, RedirectView, TemplateView from django.views.generic.edit import ( FormView, CreateView, UpdateView, DeleteView, FormMixin ) @@ -21,6 +23,9 @@ from django.contrib import messages from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from django.forms.models import model_to_dict +from django.db.models import ( + Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value +) from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment @@ -30,13 +35,18 @@ from accounts.utils import get_or_create_customer from accounts.forms import ( AddressForm as AccountAddressForm, CustomerUpdateForm ) -from core.models import Product, Order, Transaction, OrderLine, Coupon -from core.forms import ShippingMethodForm +from core.models import ( + ProductCategory, Product, ProductVariant, ProductOption, + Order, Transaction, OrderLine, Coupon, ShippingRate, + SiteSettings +) +from core.forms import ShippingRateForm from core import OrderStatus, ShippingContainer from .forms import ( AddToCartForm, UpdateCartItemForm, OrderCreateForm, - AddressForm, CouponApplyForm, CheckoutShippingForm, + AddressForm, CouponApplyForm, ContactForm, CheckoutShippingForm, + SubscriptionCreateForm ) from .cart import Cart from .payments import CaptureOrder @@ -50,13 +60,13 @@ class CartView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) cart = Cart(self.request) - for item in cart: - for variation in item['variations'].values(): - variation['update_quantity_form'] = UpdateCartItemForm( - initial={ - 'quantity': variation['quantity'] - } - ) + for i, item in enumerate(cart): + item['update_quantity_form'] = UpdateCartItemForm( + initial={ + 'item_pk': i, + 'quantity': item['quantity'] + } + ) context['cart'] = cart context['coupon_apply_form'] = CouponApplyForm() return context @@ -70,23 +80,35 @@ class CartAddProductView(SingleObjectMixin, FormView): def get_success_url(self): return reverse('storefront:cart-detail') + def get_form(self, form_class=None): + variants = self.get_object().variants.filter( + Q(track_inventory=False) | Q( + track_inventory=True, + stock__gt=0 + ) + ) + options = ProductOption.objects.filter(products__pk=self.get_object().pk) + if form_class is None: + form_class = self.get_form_class() + return form_class(variants, options, **self.get_form_kwargs()) + def post(self, request, *args, **kwargs): cart = Cart(request) form = self.get_form() if form.is_valid(): + cleaned_data = form.cleaned_data cart.add( request=request, - product=self.get_object(), - grind=form.cleaned_data['grind'], - quantity=form.cleaned_data['quantity'] + item={ + 'variant': cleaned_data.pop('variant'), + 'quantity': cleaned_data.pop('quantity'), + 'options': cleaned_data + } ) return self.form_valid(form) else: return self.form_invalid(form) - def form_valid(self, form): - return super().form_valid(form) - class CartUpdateProductView(SingleObjectMixin, FormView): model = Product @@ -102,9 +124,10 @@ class CartUpdateProductView(SingleObjectMixin, FormView): if form.is_valid(): cart.add( request=request, - product=self.get_object(), - grind=kwargs['grind'], - quantity=form.cleaned_data['quantity'], + item={ + 'variant': form.cleaned_data['item_pk'], + 'quantity': form.cleaned_data['quantity'] + }, update_quantity=form.cleaned_data['update'] ) return self.form_valid(form) @@ -115,10 +138,9 @@ class CartUpdateProductView(SingleObjectMixin, FormView): return super().form_valid(form) -def cart_remove_product_view(request, pk, grind): +def cart_remove_product_view(request, pk): cart = Cart(request) - product = get_object_or_404(Product, id=pk) - cart.remove(product, grind) + cart.remove(pk) return redirect('storefront:cart-detail') @@ -143,14 +165,33 @@ class CouponApplyView(FormView): return super().form_valid(form) -class ProductListView(FormMixin, ListView): +class ProductCategoryDetailView(DetailView): + model = ProductCategory + template_name = 'storefront/category_detail.html' + context_object_name = 'category' + + def get_queryset(self): + object_list = ProductCategory.objects.prefetch_related( + Prefetch( + 'product_set', + queryset=Product.objects.filter( + Q(visible_in_listings=True), + Q(variants__track_inventory=False) | + Q(variants__track_inventory=True) & Q(variants__stock__gt=0) + ).distinct() + ) + ) + return object_list + + +class ProductListView(ListView): model = Product template_name = 'storefront/product_list.html' - form_class = AddToCartForm ordering = 'sorting' queryset = Product.objects.filter( - visible_in_listings=True + visible_in_listings=True, + category__main_category=True ) @@ -159,6 +200,18 @@ class ProductDetailView(FormMixin, DetailView): template_name = 'storefront/product_detail.html' form_class = AddToCartForm + def get_form(self, form_class=None): + variants = self.object.variants.filter( + Q(track_inventory=False) | Q( + track_inventory=True, + stock__gt=0 + ) + ) + options = ProductOption.objects.filter(products__pk=self.object.pk) + if form_class is None: + form_class = self.get_form_class() + return form_class(variants, options, **self.get_form_kwargs()) + class CheckoutAddressView(FormView): template_name = 'storefront/checkout_address.html' @@ -171,7 +224,7 @@ class CheckoutAddressView(FormView): if user.is_authenticated and user.default_shipping_address: address = user.default_shipping_address initial = { - 'full_name': address.first_name+' '+address.last_name, + 'full_name': address.first_name + ' ' + address.last_name, 'email': user.email, 'street_address_1': address.street_address_1, 'street_address_2': address.street_address_2, @@ -182,7 +235,7 @@ class CheckoutAddressView(FormView): elif self.request.session.get('shipping_address'): address = self.request.session.get('shipping_address') initial = { - 'full_name': address['first_name']+' '+address['last_name'], + 'full_name': address['first_name'] + ' ' + address['last_name'], 'email': address['email'], 'street_address_1': address['street_address_1'], 'street_address_2': address['street_address_2'], @@ -216,40 +269,47 @@ class CheckoutShippingView(FormView): template_name = 'storefront/checkout_shipping_form.html' form_class = CheckoutShippingForm success_url = reverse_lazy('storefront:order-create') + containers = None + + def get_containers(self, request): + if self.containers is None: + cart = Cart(request) + self.containers = cart.get_shipping_container_choices() + return self.containers def get(self, request, *args, **kwargs): - cart = Cart(request) - if len(cart) != 6: - if 'shipping_container' in self.request.session: - del self.request.session['shipping_container'] - return HttpResponseRedirect( - reverse('storefront:order-create') - ) - - if not self.request.session.get("shipping_address"): + if not self.request.session.get('shipping_address'): messages.warning(request, 'Please add a shipping address.') return HttpResponseRedirect( reverse('storefront:checkout-address') ) - + site_settings = cache.get('SiteSettings') + cart = Cart(self.request) + if len(self.get_containers(request)) == 0: + self.request.session['shipping_container'] = site_settings.default_shipping_rate + return HttpResponseRedirect( + reverse('storefront:order-create') + ) + elif len(self.get_containers(request)) == 1: + self.request.session['shipping_container'] = self.get_containers(request)[0] + return HttpResponseRedirect( + reverse('storefront:order-create') + ) return super().get(request, *args, **kwargs) - def get_context_data(self, **kwargs): + def get_form(self, form_class=None): cart = Cart(self.request) - context = super().get_context_data(**kwargs) - context['MD_FLAT_RATE_BOX'] = cart.get_shipping_cost( - ShippingContainer.MD_FLAT_RATE_BOX - ) - context['REGIONAL_RATE_BOX_B'] = cart.get_shipping_cost( - ShippingContainer.REGIONAL_RATE_BOX_B - ) - return context + for container in self.get_containers(self.request): + container.s_cost = cart.get_shipping_cost(container.container) + if form_class is None: + form_class = self.get_form_class() + return form_class(self.get_containers(self.request), **self.get_form_kwargs()) def form_valid(self, form): - cleaned_data = form.cleaned_data - self.request.session['shipping_container'] = cleaned_data.get( - 'shipping_method' + shipping_container = ShippingRate.objects.get( + pk=form.cleaned_data.get('shipping_method') ) + self.request.session['shipping_container'] = shipping_container return super().form_valid(form) @@ -260,17 +320,13 @@ class OrderCreateView(CreateView): success_url = reverse_lazy('storefront:payment-done') def get(self, request, *args, **kwargs): - cart = Cart(request) - if len(cart) != 6 and 'shipping_container' in self.request.session: - del self.request.session['shipping_container'] - - if not self.request.session.get("shipping_address"): + if not self.request.session.get('shipping_address'): messages.warning(request, 'Please add a shipping address.') return HttpResponseRedirect( reverse('storefront:checkout-address') ) elif self.request.session.get('coupon_code'): - address = self.request.session.get("shipping_address") + address = self.request.session.get('shipping_address') coupon = Coupon.objects.get( code=self.request.session.get('coupon_code') ) @@ -281,19 +337,22 @@ class OrderCreateView(CreateView): if user in coupon.users.all(): del self.request.session['coupon_code'] messages.warning(request, 'Coupon already used.') - return super().get(request, *args, **kwargs) def get_initial(self): cart = Cart(self.request) + shipping_container = self.request.session.get( + 'shipping_container' + ).container try: - shipping_cost = cart.get_shipping_cost() + shipping_cost = cart.get_shipping_cost(shipping_container) except Exception as e: - raise e('Could not get shipping information') + logger.error('Could not get shipping information') + raise shipping_cost = Decimal('0.00') initial = { - 'total_net_amount': cart.get_total_price(), + 'total_amount': cart.get_total_price(), 'shipping_total': shipping_cost } if self.request.session.get('shipping_address'): @@ -315,8 +374,12 @@ class OrderCreateView(CreateView): def form_valid(self, form): cart = Cart(self.request) + form.instance.subtotal_amount = cart.get_subtotal_price_after_discount() + form.instance.coupon_amount = cart.get_discount() + form.instance.total_amount = cart.get_total_price_after_discount() + form.instance.weight = cart.get_total_weight() shipping_address = self.request.session.get('shipping_address') - shipping_container = self.request.session.get('shipping_container') + shipping_container = self.request.session.get('shipping_container').container form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address) form.instance.status = OrderStatus.DRAFT self.object = form.save() @@ -339,6 +402,7 @@ def paypal_order_transaction_capture(request, transaction_id): cart = Cart(request) order = Order.objects.get(pk=request.session.get('order_id')) order.status = OrderStatus.UNFULFILLED + order.minus_stock() try: coupon = Coupon.objects.get( code=request.session.get('coupon_code') @@ -356,20 +420,11 @@ def paypal_order_transaction_capture(request, transaction_id): transaction.save() cart.clear() logger.debug(f'\nPayPal Response data: {data}\n') - return JsonResponse(data) else: return JsonResponse({'details': 'invalid request'}) -@csrf_exempt -@require_POST -def paypal_webhook_endpoint(request): - data = json.loads(request.body) - logger.info(data) - return JsonResponse(data) - - class PaymentDoneView(TemplateView): template_name = 'storefront/payment_done.html' @@ -491,3 +546,43 @@ class FairTradeView(TemplateView): class ReviewListView(TemplateView): template_name = 'storefront/reviews.html' + + +class ContactFormView(FormView, SuccessMessageMixin): + template_name = 'storefront/contact_form.html' + form_class = ContactForm + success_url = reverse_lazy('storefront:product-list') + success_message = 'Message sent.' + + def form_valid(self, form): + form.send_email() + return super().form_valid(form) + + +class SubscriptionCreateView(FormView): + template_name = 'storefront/subscriptions.html' + form_class = SubscriptionCreateForm + success_url = reverse_lazy('storefront:payment-done') + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['STRIPE_API_KEY'] = settings.STRIPE_API_KEY + context['product_list'] = Product.objects.filter( + visible_in_listings=True + ) + return context + + +class CreatePayment(View): + def post(self, request, *args, **kwargs): + stripe.api_key = settings.STRIPE_API_KEY + intent = stripe.PaymentIntent.create( + amount=2000, + currency=settings.DEFAULT_CURRENCY, + automatic_payment_methods={ + 'enabled': True, + }, + ) + return JsonResponse({ + 'clientSecret': intent['client_secret'] + }) diff --git a/src/templates/base.html b/src/templates/base.html index 05f88a8..c8c82d5 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -17,8 +17,8 @@ {% compress css %} - - + + {% endcompress %} @@ -46,7 +46,15 @@