diff --git a/Pipfile b/Pipfile index 6f56539..ab813a8 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ paypal-checkout-serversdk = "*" Pillow = "*" redis = "*" psycopg2 = "*" +usps-api = "*" [dev-packages] django-debug-toolbar = "*" diff --git a/Pipfile.lock b/Pipfile.lock index d83b1c7..f943a4c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f7ca5d3f9e367e3324d6fd9af8c1022cff36b1a999eb7afa232ac1af8404c62c" + "sha256": "0087f9e4fd44233bc6329f7a844a96ae4001ee9d173323e7a715c7295844163c" }, "pipfile-spec": 6, "requires": { @@ -32,6 +32,14 @@ "markers": "python_version >= '3.7'", "version": "==3.5.0" }, + "async-timeout": { + "hashes": [ + "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", + "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" + ], + "markers": "python_version >= '3.6'", + "version": "==4.0.2" + }, "billiard": { "hashes": [ "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547", @@ -44,11 +52,11 @@ "redis" ], "hashes": [ - "sha256:8aacd02fc23a02760686d63dde1eb0daa9f594e735e73ea8fb15c2ff15cb608c", - "sha256:e2cd41667ad97d4f6a2f4672d1c6a6ebada194c619253058b5f23704aaadaa82" + "sha256:d1398cadf30f576266b34370e28e880306ec55f7a4b6307549b0ae9c15663481", + "sha256:da31f8eae7607b1582e5ee2d3f2d6f58450585afd23379491e3d9229d08102d0" ], "index": "pypi", - "version": "==5.2.3" + "version": "==5.2.6" }, "certifi": { "hashes": [ @@ -201,10 +209,10 @@ }, "django-allauth": { "hashes": [ - "sha256:f5fbb67376177c6a9276516dde98bcb01ac4160a5a27f7b340914dd521d04f12" + "sha256:ee3a174e249771caeb1d037e64b2704dd3c56cfec44f2058fae2214b224d35e8" ], "index": "pypi", - "version": "==0.49.0" + "version": "==0.50.0" }, "django-anymail": { "extras": [ @@ -321,6 +329,73 @@ "markers": "python_version >= '3.7'", "version": "==5.2.4" }, + "lxml": { + "hashes": [ + "sha256:078306d19a33920004addeb5f4630781aaeabb6a8d01398045fcde085091a169", + "sha256:0c1978ff1fd81ed9dcbba4f91cf09faf1f8082c9d72eb122e92294716c605428", + "sha256:1010042bfcac2b2dc6098260a2ed022968dbdfaf285fc65a3acf8e4eb1ffd1bc", + "sha256:1d650812b52d98679ed6c6b3b55cbb8fe5a5460a0aef29aeb08dc0b44577df85", + "sha256:20b8a746a026017acf07da39fdb10aa80ad9877046c9182442bf80c84a1c4696", + "sha256:2403a6d6fb61c285969b71f4a3527873fe93fd0abe0832d858a17fe68c8fa507", + "sha256:24f5c5ae618395ed871b3d8ebfcbb36e3f1091fd847bf54c4de623f9107942f3", + "sha256:28d1af847786f68bec57961f31221125c29d6f52d9187c01cd34dc14e2b29430", + "sha256:31499847fc5f73ee17dbe1b8e24c6dafc4e8d5b48803d17d22988976b0171f03", + "sha256:31ba2cbc64516dcdd6c24418daa7abff989ddf3ba6d3ea6f6ce6f2ed6e754ec9", + "sha256:330bff92c26d4aee79c5bc4d9967858bdbe73fdbdbacb5daf623a03a914fe05b", + "sha256:5045ee1ccd45a89c4daec1160217d363fcd23811e26734688007c26f28c9e9e7", + "sha256:52cbf2ff155b19dc4d4100f7442f6a697938bf4493f8d3b0c51d45568d5666b5", + "sha256:530f278849031b0eb12f46cca0e5db01cfe5177ab13bd6878c6e739319bae654", + "sha256:545bd39c9481f2e3f2727c78c169425efbfb3fbba6e7db4f46a80ebb249819ca", + "sha256:5804e04feb4e61babf3911c2a974a5b86f66ee227cc5006230b00ac6d285b3a9", + "sha256:5a58d0b12f5053e270510bf12f753a76aaf3d74c453c00942ed7d2c804ca845c", + "sha256:5f148b0c6133fb928503cfcdfdba395010f997aa44bcf6474fcdd0c5398d9b63", + "sha256:5f7d7d9afc7b293147e2d506a4596641d60181a35279ef3aa5778d0d9d9123fe", + "sha256:60d2f60bd5a2a979df28ab309352cdcf8181bda0cca4529769a945f09aba06f9", + "sha256:6259b511b0f2527e6d55ad87acc1c07b3cbffc3d5e050d7e7bcfa151b8202df9", + "sha256:6268e27873a3d191849204d00d03f65c0e343b3bcb518a6eaae05677c95621d1", + "sha256:627e79894770783c129cc5e89b947e52aa26e8e0557c7e205368a809da4b7939", + "sha256:62f93eac69ec0f4be98d1b96f4d6b964855b8255c345c17ff12c20b93f247b68", + "sha256:6d6483b1229470e1d8835e52e0ff3c6973b9b97b24cd1c116dca90b57a2cc613", + "sha256:6f7b82934c08e28a2d537d870293236b1000d94d0b4583825ab9649aef7ddf63", + "sha256:6fe4ef4402df0250b75ba876c3795510d782def5c1e63890bde02d622570d39e", + "sha256:719544565c2937c21a6f76d520e6e52b726d132815adb3447ccffbe9f44203c4", + "sha256:730766072fd5dcb219dd2b95c4c49752a54f00157f322bc6d71f7d2a31fecd79", + "sha256:74eb65ec61e3c7c019d7169387d1b6ffcfea1b9ec5894d116a9a903636e4a0b1", + "sha256:7993232bd4044392c47779a3c7e8889fea6883be46281d45a81451acfd704d7e", + "sha256:80bbaddf2baab7e6de4bc47405e34948e694a9efe0861c61cdc23aa774fcb141", + "sha256:86545e351e879d0b72b620db6a3b96346921fa87b3d366d6c074e5a9a0b8dadb", + "sha256:891dc8f522d7059ff0024cd3ae79fd224752676447f9c678f2a5c14b84d9a939", + "sha256:8a31f24e2a0b6317f33aafbb2f0895c0bce772980ae60c2c640d82caac49628a", + "sha256:8b99ec73073b37f9ebe8caf399001848fced9c08064effdbfc4da2b5a8d07b93", + "sha256:986b7a96228c9b4942ec420eff37556c5777bfba6758edcb95421e4a614b57f9", + "sha256:a1547ff4b8a833511eeaceacbcd17b043214fcdb385148f9c1bc5556ca9623e2", + "sha256:a2bfc7e2a0601b475477c954bf167dee6d0f55cb167e3f3e7cefad906e7759f6", + "sha256:a3c5f1a719aa11866ffc530d54ad965063a8cbbecae6515acbd5f0fae8f48eaa", + "sha256:a9f1c3489736ff8e1c7652e9dc39f80cff820f23624f23d9eab6e122ac99b150", + "sha256:aa0cf4922da7a3c905d000b35065df6184c0dc1d866dd3b86fd961905bbad2ea", + "sha256:ad4332a532e2d5acb231a2e5d33f943750091ee435daffca3fec0a53224e7e33", + "sha256:b2582b238e1658c4061ebe1b4df53c435190d22457642377fd0cb30685cdfb76", + "sha256:b6fc2e2fb6f532cf48b5fed57567ef286addcef38c28874458a41b7837a57807", + "sha256:b92d40121dcbd74831b690a75533da703750f7041b4bf951befc657c37e5695a", + "sha256:bbab6faf6568484707acc052f4dfc3802bdb0cafe079383fbaa23f1cdae9ecd4", + "sha256:c0b88ed1ae66777a798dc54f627e32d3b81c8009967c63993c450ee4cbcbec15", + "sha256:ce13d6291a5f47c1c8dbd375baa78551053bc6b5e5c0e9bb8e39c0a8359fd52f", + "sha256:db3535733f59e5605a88a706824dfcb9bd06725e709ecb017e165fc1d6e7d429", + "sha256:dd10383f1d6b7edf247d0960a3db274c07e96cf3a3fc7c41c8448f93eac3fb1c", + "sha256:e01f9531ba5420838c801c21c1b0f45dbc9607cb22ea2cf132844453bec863a5", + "sha256:e11527dc23d5ef44d76fef11213215c34f36af1608074561fcc561d983aeb870", + "sha256:e1ab2fac607842ac36864e358c42feb0960ae62c34aa4caaf12ada0a1fb5d99b", + "sha256:e1fd7d2fe11f1cb63d3336d147c852f6d07de0d0020d704c6031b46a30b02ca8", + "sha256:e9f84ed9f4d50b74fbc77298ee5c870f67cb7e91dcdc1a6915cb1ff6a317476c", + "sha256:ec4b4e75fc68da9dc0ed73dcdb431c25c57775383fec325d23a770a64e7ebc87", + "sha256:f10ce66fcdeb3543df51d423ede7e238be98412232fca5daec3e54bcd16b8da0", + "sha256:f63f62fc60e6228a4ca9abae28228f35e1bd3ce675013d1dfb828688d50c6e23", + "sha256:fa56bb08b3dd8eac3a8c5b7d075c94e74f755fd9d8a04543ae8d37b1612dd170", + "sha256:fa9b7c450be85bfc6cd39f6df8c5b8cbd76b5d6fc1f69efec80203f9894b885f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.8.0" + }, "measurement": { "hashes": [ "sha256:352b20f7f0e553236af7c5ed48d091a51cf26061c1a063f46b31706ff7c0d57a" @@ -368,52 +443,55 @@ }, "pillow": { "hashes": [ - "sha256:011233e0c42a4a7836498e98c1acf5e744c96a67dd5032a6f666cc1fb97eab97", - "sha256:0f29d831e2151e0b7b39981756d201f7108d3d215896212ffe2e992d06bfe049", - "sha256:12875d118f21cf35604176872447cdb57b07126750a33748bac15e77f90f1f9c", - "sha256:14d4b1341ac07ae07eb2cc682f459bec932a380c3b122f5540432d8977e64eae", - "sha256:1c3c33ac69cf059bbb9d1a71eeaba76781b450bc307e2291f8a4764d779a6b28", - "sha256:1d19397351f73a88904ad1aee421e800fe4bbcd1aeee6435fb62d0a05ccd1030", - "sha256:253e8a302a96df6927310a9d44e6103055e8fb96a6822f8b7f514bb7ef77de56", - "sha256:2632d0f846b7c7600edf53c48f8f9f1e13e62f66a6dbc15191029d950bfed976", - "sha256:335ace1a22325395c4ea88e00ba3dc89ca029bd66bd5a3c382d53e44f0ccd77e", - "sha256:413ce0bbf9fc6278b2d63309dfeefe452835e1c78398efb431bab0672fe9274e", - "sha256:5100b45a4638e3c00e4d2320d3193bdabb2d75e79793af7c3eb139e4f569f16f", - "sha256:514ceac913076feefbeaf89771fd6febde78b0c4c1b23aaeab082c41c694e81b", - "sha256:528a2a692c65dd5cafc130de286030af251d2ee0483a5bf50c9348aefe834e8a", - "sha256:6295f6763749b89c994fcb6d8a7f7ce03c3992e695f89f00b741b4580b199b7e", - "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa", - "sha256:718856856ba31f14f13ba885ff13874be7fefc53984d2832458f12c38205f7f7", - "sha256:7f7609a718b177bf171ac93cea9fd2ddc0e03e84d8fa4e887bdfc39671d46b00", - "sha256:80ca33961ced9c63358056bd08403ff866512038883e74f3a4bf88ad3eb66838", - "sha256:80fe64a6deb6fcfdf7b8386f2cf216d329be6f2781f7d90304351811fb591360", - "sha256:81c4b81611e3a3cb30e59b0cf05b888c675f97e3adb2c8672c3154047980726b", - "sha256:855c583f268edde09474b081e3ddcd5cf3b20c12f26e0d434e1386cc5d318e7a", - "sha256:9bfdb82cdfeccec50aad441afc332faf8606dfa5e8efd18a6692b5d6e79f00fd", - "sha256:a5d24e1d674dd9d72c66ad3ea9131322819ff86250b30dc5821cbafcfa0b96b4", - "sha256:a9f44cd7e162ac6191491d7249cceb02b8116b0f7e847ee33f739d7cb1ea1f70", - "sha256:b5b3f092fe345c03bca1e0b687dfbb39364b21ebb8ba90e3fa707374b7915204", - "sha256:b9618823bd237c0d2575283f2939655f54d51b4527ec3972907a927acbcc5bfc", - "sha256:cef9c85ccbe9bee00909758936ea841ef12035296c748aaceee535969e27d31b", - "sha256:d21237d0cd37acded35154e29aec853e945950321dd2ffd1a7d86fe686814669", - "sha256:d3c5c79ab7dfce6d88f1ba639b77e77a17ea33a01b07b99840d6ed08031cb2a7", - "sha256:d9d7942b624b04b895cb95af03a23407f17646815495ce4547f0e60e0b06f58e", - "sha256:db6d9fac65bd08cea7f3540b899977c6dee9edad959fa4eaf305940d9cbd861c", - "sha256:ede5af4a2702444a832a800b8eb7f0a7a1c0eed55b644642e049c98d589e5092", - "sha256:effb7749713d5317478bb3acb3f81d9d7c7f86726d41c1facca068a04cf5bb4c", - "sha256:f154d173286a5d1863637a7dcd8c3437bb557520b01bddb0be0258dcb72696b5", - "sha256:f25ed6e28ddf50de7e7ea99d7a976d6a9c415f03adcaac9c41ff6ff41b6d86ac" + "sha256:01ce45deec9df310cbbee11104bae1a2a43308dd9c317f99235b6d3080ddd66e", + "sha256:0c51cb9edac8a5abd069fd0758ac0a8bfe52c261ee0e330f363548aca6893595", + "sha256:17869489de2fce6c36690a0c721bd3db176194af5f39249c1ac56d0bb0fcc512", + "sha256:21dee8466b42912335151d24c1665fcf44dc2ee47e021d233a40c3ca5adae59c", + "sha256:25023a6209a4d7c42154073144608c9a71d3512b648a2f5d4465182cb93d3477", + "sha256:255c9d69754a4c90b0ee484967fc8818c7ff8311c6dddcc43a4340e10cd1636a", + "sha256:35be4a9f65441d9982240e6966c1eaa1c654c4e5e931eaf580130409e31804d4", + "sha256:3f42364485bfdab19c1373b5cd62f7c5ab7cc052e19644862ec8f15bb8af289e", + "sha256:3fddcdb619ba04491e8f771636583a7cc5a5051cd193ff1aa1ee8616d2a692c5", + "sha256:463acf531f5d0925ca55904fa668bb3461c3ef6bc779e1d6d8a488092bdee378", + "sha256:4fe29a070de394e449fd88ebe1624d1e2d7ddeed4c12e0b31624561b58948d9a", + "sha256:55dd1cf09a1fd7c7b78425967aacae9b0d70125f7d3ab973fadc7b5abc3de652", + "sha256:5a3ecc026ea0e14d0ad7cd990ea7f48bfcb3eb4271034657dc9d06933c6629a7", + "sha256:5cfca31ab4c13552a0f354c87fbd7f162a4fafd25e6b521bba93a57fe6a3700a", + "sha256:66822d01e82506a19407d1afc104c3fcea3b81d5eb11485e593ad6b8492f995a", + "sha256:69e5ddc609230d4408277af135c5b5c8fe7a54b2bdb8ad7c5100b86b3aab04c6", + "sha256:6b6d4050b208c8ff886fd3db6690bf04f9a48749d78b41b7a5bf24c236ab0165", + "sha256:7a053bd4d65a3294b153bdd7724dce864a1d548416a5ef61f6d03bf149205160", + "sha256:82283af99c1c3a5ba1da44c67296d5aad19f11c535b551a5ae55328a317ce331", + "sha256:8782189c796eff29dbb37dd87afa4ad4d40fc90b2742704f94812851b725964b", + "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458", + "sha256:97bda660702a856c2c9e12ec26fc6d187631ddfd896ff685814ab21ef0597033", + "sha256:a325ac71914c5c043fa50441b36606e64a10cd262de12f7a179620f579752ff8", + "sha256:a336a4f74baf67e26f3acc4d61c913e378e931817cd1e2ef4dfb79d3e051b481", + "sha256:a598d8830f6ef5501002ae85c7dbfcd9c27cc4efc02a1989369303ba85573e58", + "sha256:a5eaf3b42df2bcda61c53a742ee2c6e63f777d0e085bbc6b2ab7ed57deb13db7", + "sha256:aea7ce61328e15943d7b9eaca87e81f7c62ff90f669116f857262e9da4057ba3", + "sha256:af79d3fde1fc2e33561166d62e3b63f0cc3e47b5a3a2e5fea40d4917754734ea", + "sha256:c24f718f9dd73bb2b31a6201e6db5ea4a61fdd1d1c200f43ee585fc6dcd21b34", + "sha256:c5b0ff59785d93b3437c3703e3c64c178aabada51dea2a7f2c5eccf1bcf565a3", + "sha256:c7110ec1701b0bf8df569a7592a196c9d07c764a0a74f65471ea56816f10e2c8", + "sha256:c870193cce4b76713a2b29be5d8327c8ccbe0d4a49bc22968aa1e680930f5581", + "sha256:c9efef876c21788366ea1f50ecb39d5d6f65febe25ad1d4c0b8dff98843ac244", + "sha256:de344bcf6e2463bb25179d74d6e7989e375f906bcec8cb86edb8b12acbc7dfef", + "sha256:eb1b89b11256b5b6cad5e7593f9061ac4624f7651f7a8eb4dfa37caa1dfaa4d0", + "sha256:ed742214068efa95e9844c2d9129e209ed63f61baa4d54dbf4cf8b5e2d30ccf2", + "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97", + "sha256:fb89397013cf302f282f0fc998bb7abf11d49dcff72c8ecb320f76ea6e2c5717" ], "index": "pypi", - "version": "==9.0.1" + "version": "==9.1.0" }, "prompt-toolkit": { "hashes": [ - "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c", - "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650" + "sha256:62291dad495e665fca0bda814e342c69952086afb0f4094d0893d357e5c78752", + "sha256:bd640f60e8cecd74f0dc249713d433ace2ddc62b65ee07f96d358e0b152b6ea7" ], "markers": "python_full_version >= '3.6.2'", - "version": "==3.0.28" + "version": "==3.0.29" }, "psycopg2": { "hashes": [ @@ -568,11 +646,11 @@ }, "redis": { "hashes": [ - "sha256:04629f8e42be942c4f7d1812f2094568f04c612865ad19ad3ace3005da70631a", - "sha256:1d9a0cdf89fdd93f84261733e24f55a7bbd413a9b219fdaf56e3e728ca9a2306" + "sha256:0107dc8e98a4f1d1d4aa00100e044287f77121a1e6d2085545c4b7fa94a7a27f", + "sha256:4e95f4ec5f49e636efcf20061a5a9110c20852f607cfca6865c07aaa8a739ee2" ], "index": "pypi", - "version": "==4.1.4" + "version": "==4.2.2" }, "requests": { "hashes": [ @@ -615,14 +693,6 @@ ], "version": "==1.2.0" }, - "setuptools": { - "hashes": [ - "sha256:22c7348c6d2976a52632c67f7ab0cdf40147db7789f9aed18734643fe9cf3373", - "sha256:4ce92f1e1f8f01233ee9952c04f6b81d1e02939d6e1b488428154974a4d0783e" - ], - "markers": "python_version >= '3.6'", - "version": "==59.6.0" - }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", @@ -655,6 +725,13 @@ "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" }, + "usps-api": { + "hashes": [ + "sha256:20c2f19e02dde3eac01b794d6a4c1bcad03ab681d67a8579d090dbda5e5247a3" + ], + "index": "pypi", + "version": "==0.5" + }, "vine": { "hashes": [ "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", @@ -739,6 +816,13 @@ ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.14.0" + }, + "xmltodict": { + "hashes": [ + "sha256:50d8c638ed7ecb88d90561beedbf720c9b4e851a9fa6c47ebd64e99d166d8a21", + "sha256:8bbcb45cc982f48b2ca8fe7e7827c5d792f217ecf1792626f808bf41c3b86051" + ], + "version": "==0.12.0" } }, "develop": { @@ -951,7 +1035,7 @@ "sha256:670a52d3115d0e879e1ac838a4eb999af32f858163e3a704fe4839de2a676070", "sha256:fb2d48e4eab0dfb786a472cd514aaadc71e3445b203bc300bad93daa75d77c1a" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==0.20.0" }, "trio-websocket": { @@ -975,7 +1059,7 @@ "sha256:2218cb57952d90b9fca325c0dcfb08c3bda93e8fd8070b0a17f048e2e47a521b", "sha256:a2e56bfd5c7cd83c1369d83b5feccd6d37798b74872866e62616e0ecf111bda8" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.1.0" } } diff --git a/src/accounts/forms.py b/src/accounts/forms.py index aa7e2c5..aa0d7b8 100644 --- a/src/accounts/forms.py +++ b/src/accounts/forms.py @@ -5,7 +5,15 @@ from .models import Address, User class AddressForm(forms.ModelForm): class Meta: model = Address - fields = '__all__' + fields = ( + 'first_name', + 'last_name', + 'street_address_1', + 'street_address_2', + 'city', + 'state', + 'postal_code', + ) class AccountCreateForm(UserCreationForm): @@ -16,8 +24,24 @@ class AccountCreateForm(UserCreationForm): class AccountUpdateForm(UserChangeForm): class Meta: model = User - fields = [ + fields = ( 'first_name', 'last_name', 'email', - ] + 'default_shipping_address', + 'addresses', + ) + +class CustomerUpdateForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['default_shipping_address'].queryset = kwargs['instance'].addresses + + class Meta: + model = User + fields = ( + 'first_name', + 'last_name', + 'email', + 'default_shipping_address', + ) diff --git a/src/core/apps.py b/src/core/apps.py index 8e2dd6c..cf4aa6c 100644 --- a/src/core/apps.py +++ b/src/core/apps.py @@ -6,4 +6,9 @@ class CoreConfig(AppConfig): name = 'core' def ready(self): - from .signals import order_created, transaction_created, order_line_post_save + from .signals import ( + order_created, + transaction_created, + order_line_post_save, + trackingnumber_postsave + ) 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 new file mode 100644 index 0000000..96dd6bd --- /dev/null +++ b/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py @@ -0,0 +1,36 @@ +# 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/0003_trackingnumber_created_at_trackingnumber_updated_at.py b/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py new file mode 100644 index 0000000..170c781 --- /dev/null +++ b/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 0000000..f33dc45 --- /dev/null +++ b/src/core/migrations/0004_order_coupon.py @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 0000000..a7eca60 --- /dev/null +++ b/src/core/migrations/0005_alter_product_options_product_sorting.py @@ -0,0 +1,22 @@ +# 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/models.py b/src/core/models.py index 93441f8..a64e201 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -49,6 +49,7 @@ class Product(models.Model): ) 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) @@ -61,6 +62,9 @@ class Product(models.Model): def get_absolute_url(self): return reverse('dashboard:product-detail', kwargs={'pk': self.pk}) + class Meta: + ordering = ['sorting', 'name'] + class ProductPhoto(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) @@ -69,6 +73,12 @@ class ProductPhoto(models.Model): def __str__(self): return self.product.name + def delete(self, *args, **kwargs): + storage, path = self.image.storage, self.image.path + + super(ProductPhoto, self).delete(*args, **kwargs) + storage.delete(path) + # def save(self, *args, **kwargs): # super().save(*args, **kwargs) @@ -105,17 +115,31 @@ class Coupon(models.Model): class Meta: ordering = ("code",) + def __str__(self): + return self.name + @property def is_valid(self): - today = timezone.localtime(timezone.now()).date() + today = timezone.localtime(timezone.now()) return True if today >= self.valid_from and today <= self.valid_to else False + def get_absolute_url(self): + 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, + ) + + def get_absolute_url(self): + return reverse('dashboard:shipmeth-detail', kwargs={'pk': self.pk}) class OrderManager(models.Manager): @@ -167,6 +191,13 @@ class Order(models.Model): on_delete=models.SET_NULL, ) + coupon = models.ForeignKey( + Coupon, + related_name='orders', + on_delete=models.SET_NULL, + null=True + ) + total_net_amount = models.DecimalField( max_digits=10, decimal_places=2, @@ -188,9 +219,23 @@ class Order(models.Model): def get_total_quantity(self): return sum([line.quantity for line in self]) + def get_discount(self): + if self.coupon: + 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 Decimal('0') + + def get_total_price_after_discount(self): + return round(self.total_net_amount - self.get_discount(), 2) + def get_absolute_url(self): return reverse('dashboard:order-detail', kwargs={'pk': self.pk}) + class Meta: + ordering = ('-created_at',) + class Transaction(models.Model): @@ -251,3 +296,24 @@ class OrderLine(models.Model): @property def quantity_unfulfilled(self): return self.quantity - self.quantity_fulfilled + + +class TrackingNumber(models.Model): + order = models.ForeignKey( + Order, + related_name="tracking_numbers", + editable=False, + on_delete=models.CASCADE + ) + tracking_id = models.CharField(max_length=256) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Tracking Number' + verbose_name_plural = 'Tracking Numbers' + + def __str__(self): + return self.tracking_id + diff --git a/src/core/signals.py b/src/core/signals.py index 85fcdba..67e1d60 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -6,8 +6,11 @@ from django.dispatch import receiver from django.db import models from . import OrderStatus, TransactionStatus -from .models import Order, OrderLine, Transaction -from .tasks import send_order_confirmation_email +from .models import Order, OrderLine, Transaction, TrackingNumber +from .tasks import ( + send_order_confirmation_email, + send_order_shipped_email +) logger = logging.getLogger(__name__) @@ -34,6 +37,20 @@ 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: + logger.info("TrackingNumber was created") + + data = { + 'order_id': instance.order.pk, + 'email': instance.order.customer.email, + 'full_name': instance.order.customer.get_full_name(), + 'tracking_id': instance.tracking_id + } + send_order_shipped_email.delay(data) + + def get_order_status(total_quantity_fulfilled, total_quantity_ordered): if total_quantity_fulfilled >= total_quantity_ordered: return OrderStatus.FULFILLED diff --git a/src/core/tasks.py b/src/core/tasks.py index 0211068..27d0468 100644 --- a/src/core/tasks.py +++ b/src/core/tasks.py @@ -11,6 +11,7 @@ logger = get_task_logger(__name__) CONFIRM_ORDER_TEMPLATE = 'storefront/order_confirmation' +SHIP_ORDER_TEMPLATE = 'storefront/order_shipped' ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' ORDER_REFUND_TEMPLATE = 'storefront/order_refund' @@ -24,3 +25,14 @@ 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( + template_name=SHIP_ORDER_TEMPLATE, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[data['email']], + context=data + ) + + logger.info(f"Order shipped email sent to {data['email']}") diff --git a/src/dashboard/forms.py b/src/dashboard/forms.py index 13a8394..e716884 100644 --- a/src/dashboard/forms.py +++ b/src/dashboard/forms.py @@ -1,10 +1,33 @@ import logging from django import forms -from core.models import Order, OrderLine, ShippingMethod +from core.models import Order, OrderLine, ShippingMethod, TrackingNumber, Coupon, ProductPhoto logger = logging.getLogger(__name__) +class CouponForm(forms.ModelForm): + class Meta: + model = Coupon + fields = ( + 'type', + 'name', + 'code', + 'valid_from', + 'valid_to', + 'discount_value_type', + 'discount_value', + 'products', + ) + widgets = { + 'valid_from': forms.DateInput(attrs = { + 'type': 'date' + }), + 'valid_to': forms.DateInput(attrs = { + 'type': 'date' + }), + } + + class OrderLineFulfillForm(forms.ModelForm): # send_shipment_details_to_customer = forms.BooleanField(initial=True) @@ -25,3 +48,22 @@ OrderLineFormset = forms.inlineformset_factory( Order, OrderLine, form=OrderLineFulfillForm, extra=0, can_delete=False ) + + +class OrderTrackingForm(forms.ModelForm): + # send_shipment_details_to_customer = forms.BooleanField(initial=True) + + class Meta: + model = TrackingNumber + fields = ('tracking_id',) + +OrderTrackingFormset = forms.inlineformset_factory( + Order, TrackingNumber, form=OrderTrackingForm, + extra=1, can_delete=False +) + + +class ProductPhotoForm(forms.ModelForm): + class Meta: + model = ProductPhoto + fields = ('image',) diff --git a/src/dashboard/templates/dashboard/config.html b/src/dashboard/templates/dashboard/config.html new file mode 100644 index 0000000..0874ed3 --- /dev/null +++ b/src/dashboard/templates/dashboard/config.html @@ -0,0 +1,36 @@ +{% extends "dashboard.html" %} +{% load static %} +{% load tz %} + +{% block content %} +
+
+

Site configuration

+
+ +
+
+

Shipping methods

+ + New method +
+
+ {% for method in shipping_method_list %} +

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

+ {% empty %} +

No shipping methods yet.

+ {% endfor %} +
+
+ +
+
+

Staff

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

Coupon

+
+
+
{% csrf_token %} +

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

+ {{ form.as_p }} +

+ or cancel +

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

Create coupon

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

+ or cancel +

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

{{ coupon.name }}

+
+ Delete + Edit +
+
+
+
+

{{ coupon.get_type_display }}

+

{{ coupon.code }}

+

{{ coupon.valid_from }}

+

{{ coupon.valid_to }}

+

{{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }}

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

Update Coupon

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

+ or cancel +

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

Coupons

+ +
+
+
+ Name + Code + Starts + Ends + Value +
+ {% for coupon in coupon_list %} + + {{ coupon.name }} + {{ coupon.code }} + {{ coupon.valid_from|date:"SHORT_DATE_FORMAT" }} + {{ coupon.valid_to|date:"SHORT_DATE_FORMAT" }} + {{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }} + + {% empty %} + No coupons + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/customer_detail.html b/src/dashboard/templates/dashboard/customer_detail.html index 88c7534..e724a28 100644 --- a/src/dashboard/templates/dashboard/customer_detail.html +++ b/src/dashboard/templates/dashboard/customer_detail.html @@ -5,10 +5,12 @@

Customer: {{customer.get_full_name}}

- Edit +
+ Edit +
-
+

Info

@@ -52,14 +54,14 @@
{% with order_list=customer.orders.all %}
-
+
Order # Date Status Total
{% for order in order_list %} - + #{{order.pk}} {{order.created_at|date:"D, M j Y"}} diff --git a/src/dashboard/templates/dashboard/customer_form.html b/src/dashboard/templates/dashboard/customer_form.html index 7df14ac..4d157a5 100644 --- a/src/dashboard/templates/dashboard/customer_form.html +++ b/src/dashboard/templates/dashboard/customer_form.html @@ -2,9 +2,12 @@ {% block content %}
-

Update Customer

-
-
+

← Back

+
+

Update Customer

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

diff --git a/src/dashboard/templates/dashboard/customer_list.html b/src/dashboard/templates/dashboard/customer_list.html index 223eaa8..5387de4 100644 --- a/src/dashboard/templates/dashboard/customer_list.html +++ b/src/dashboard/templates/dashboard/customer_list.html @@ -3,15 +3,17 @@ {% block content %}

-

Customers

+
+

Customers

+
-
+
Name Email Orders
{% for customer in user_list %} - + {{customer.get_full_name}} {{customer.email}} {{customer.num_orders}} diff --git a/src/dashboard/templates/dashboard/order_detail.html b/src/dashboard/templates/dashboard/order_detail.html index 02aaf88..be1a2de 100644 --- a/src/dashboard/templates/dashboard/order_detail.html +++ b/src/dashboard/templates/dashboard/order_detail.html @@ -4,7 +4,7 @@ {% block content %}
-

Order #{{order.pk}}

+

Order #{{order.pk}}

-
- Product - SKU - Quantity - Price - Total -
+
+ Product + SKU + Quantity + Price + Total +
{% for item in order.lines.all %} -
-
+

Shipping

+ Ship order →
- + {% for number in order.tracking_numbers.all %} +
+

+ Shipment
+ Date: {{number.created_at|date:"SHORT_DATE_FORMAT" }}
+ Tracking number: {{number.tracking_id}} +

+
+ {% empty %} +
+

No tracking information.

+
+ {% endfor %}
-
+

Customer

{% with customer=order.customer %} @@ -87,7 +98,22 @@
-
+
+

Payment

+
+
+

+ Subtotal: {{order.total_net_amount}}
+ {% if order.coupon %} + Discount: {{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}
+ {% endif %} + Total: {{order.get_total_price_after_discount}} +

+
+
+ +
+

Transaction

diff --git a/src/dashboard/templates/dashboard/order_fulfill.html b/src/dashboard/templates/dashboard/order_fulfill.html index 81e3027..f5f7620 100644 --- a/src/dashboard/templates/dashboard/order_fulfill.html +++ b/src/dashboard/templates/dashboard/order_fulfill.html @@ -16,14 +16,14 @@
{% endfor %} {% endfor %} -
+
Product SKU Quantity to fulfill Grind
{% for form in form %} -
+
{% with product=form.instance.product %} {{form.id}}
@@ -36,7 +36,7 @@ {% endwith %}
{% endfor %} -
+
diff --git a/src/dashboard/templates/dashboard/order_list.html b/src/dashboard/templates/dashboard/order_list.html index 6c08a12..b620945 100644 --- a/src/dashboard/templates/dashboard/order_list.html +++ b/src/dashboard/templates/dashboard/order_list.html @@ -7,7 +7,7 @@

Orders

-
+
Order # Date Customer @@ -15,7 +15,7 @@ Total
{% for order in order_list %} - + #{{order.pk}} {{order.created_at|date:"D, M j Y"}} {{order.customer.get_full_name}} diff --git a/src/dashboard/templates/dashboard/order_tracking_form.html b/src/dashboard/templates/dashboard/order_tracking_form.html new file mode 100644 index 0000000..6fef977 --- /dev/null +++ b/src/dashboard/templates/dashboard/order_tracking_form.html @@ -0,0 +1,36 @@ +{% extends "dashboard.html" %} + +{% block content %} + +{% endblock content %} diff --git a/src/dashboard/templates/dashboard/prodphoto_create_form.html b/src/dashboard/templates/dashboard/prodphoto_create_form.html new file mode 100644 index 0000000..31e4666 --- /dev/null +++ b/src/dashboard/templates/dashboard/prodphoto_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Add photo

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

+ or cancel +

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

Product

+
+
+
+ {{product.productphoto_set.first.image}} +
+
{% csrf_token %} +

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

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/product_create_form.html b/src/dashboard/templates/dashboard/product_create_form.html index 93af236..4716353 100644 --- a/src/dashboard/templates/dashboard/product_create_form.html +++ b/src/dashboard/templates/dashboard/product_create_form.html @@ -2,9 +2,11 @@ {% block content %}
-

Create product

-
-
+
+

Create product

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

diff --git a/src/dashboard/templates/dashboard/product_detail.html b/src/dashboard/templates/dashboard/product_detail.html index 50aebfa..f367505 100644 --- a/src/dashboard/templates/dashboard/product_detail.html +++ b/src/dashboard/templates/dashboard/product_detail.html @@ -5,7 +5,10 @@

Product

- Edit +
+ Delete + Edit +
@@ -17,7 +20,26 @@

${{product.price}}

{{product.weight.oz}} oz

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

-

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

+

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

+
+
+
+
+

Photos

+ + Upload new photo +
+
diff --git a/src/dashboard/templates/dashboard/product_list.html b/src/dashboard/templates/dashboard/product_list.html index a22e198..002ed80 100644 --- a/src/dashboard/templates/dashboard/product_list.html +++ b/src/dashboard/templates/dashboard/product_list.html @@ -8,14 +8,14 @@ + New product
-
+
Name Visible Price
{% for product in product_list %} - +
{{product.productphoto_set.first.image}}
diff --git a/src/dashboard/templates/dashboard/shipmeth_create_form.html b/src/dashboard/templates/dashboard/shipmeth_create_form.html new file mode 100644 index 0000000..88cd9bc --- /dev/null +++ b/src/dashboard/templates/dashboard/shipmeth_create_form.html @@ -0,0 +1,16 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/shipmeth_detail.html b/src/dashboard/templates/dashboard/shipmeth_detail.html new file mode 100644 index 0000000..dad3e49 --- /dev/null +++ b/src/dashboard/templates/dashboard/shipmeth_detail.html @@ -0,0 +1,21 @@ +{% 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/urls.py b/src/dashboard/urls.py index 25bd9f6..5f511f4 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -3,6 +3,20 @@ from . import views urlpatterns = [ path('', views.DashboardHomeView.as_view(), name='home'), + path('config/', views.DashboardConfigView.as_view(), name='config'), + + path('shipping-methods/new/', views.ShippingMethodCreateView.as_view(), name='shipmeth-create'), + path('shipping-methods//', include([ + path('', views.ShippingMethodDetailView.as_view(), name='shipmeth-detail'), + ])), + + 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('orders/', views.OrderListView.as_view(), name='order-list'), path('orders//', include([ @@ -10,14 +24,20 @@ urlpatterns = [ # path('update/', views.OrderUpdateView.as_view(), name='product-update'), # path('delete/', views.OrderDeleteView.as_view(), name='product-delete'), path('fulfill/', views.OrderFulfillView.as_view(), name='order-fulfill'), + 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('/', include([ + 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('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'), + ])), ])), path('customers/', views.CustomerListView.as_view(), name='customer-list'), diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 2549d27..7a5a50b 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -12,6 +12,8 @@ from django.views.generic.list import ListView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.forms import inlineformset_factory +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 @@ -21,14 +23,23 @@ from django.db.models.functions import Coalesce from accounts.models import User from accounts.utils import get_or_create_customer from accounts.forms import AddressForm -from core.models import Product, Order, OrderLine +from core.models import ( + Product, + ProductPhoto, + Order, + OrderLine, + ShippingMethod, + Transaction, + TrackingNumber, + Coupon +) from core import DiscountValueType, VoucherType, OrderStatus, ShippingMethodType -from .forms import OrderLineFulfillForm, OrderLineFormset +from .forms import OrderLineFulfillForm, OrderLineFormset, OrderTrackingFormset, CouponForm, ProductPhotoForm logger = logging.getLogger(__name__) -class DashboardHomeView(TemplateView): +class DashboardHomeView(LoginRequiredMixin, TemplateView): template_name = 'dashboard/dashboard_detail.html' def get_context_data(self, **kwargs): @@ -45,16 +56,70 @@ class DashboardHomeView(TemplateView): ).aggregate(total=Sum('total_net_amount'))['total'] return context -class OrderListView(ListView): +class DashboardConfigView(TemplateView): + template_name = 'dashboard/config.html' + + 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() + + return context + + + + +class ShippingMethodCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ShippingMethod + template_name = 'dashboard/shipmeth_create_form.html' + fields = '__all__' + success_message = '%(name)s created.' + +class ShippingMethodDetailView(LoginRequiredMixin, DetailView): + model = ShippingMethod + template_name = 'dashboard/shipmeth_detail.html' + + + +class CouponListView(LoginRequiredMixin, ListView): + model = Coupon + template_name = 'dashboard/coupon_list.html' + +class CouponCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = Coupon + template_name = 'dashboard/coupon_create_form.html' + form_class = CouponForm + success_message = '%(name)s created.' + +class CouponDetailView(LoginRequiredMixin, DetailView): + model = Coupon + template_name = 'dashboard/coupon_detail.html' + +class CouponUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Coupon + template_name = 'dashboard/coupon_form.html' + success_message = '%(name)s saved.' + form_class = CouponForm + +class CouponDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = Coupon + template_name = 'dashboard/coupon_confirm_delete.html' + success_url = reverse_lazy('dashboard:coupon-list') + success_message = 'Coupon deleted.' + + + +class OrderListView(LoginRequiredMixin, ListView): model = Order template_name = 'dashboard/order_list.html' def get_queryset(self): query = self.request.GET.get('status') - order = self.request.GET.get('order') - if query: + if query == 'unfulfilled': object_list = Order.objects.filter( - status=query + Q(status=OrderStatus.UNFULFILLED) | + Q(status=OrderStatus.PARTIALLY_FULFILLED) ).order_by( '-created_at' ).select_related( @@ -70,7 +135,7 @@ class OrderListView(ListView): return object_list -class OrderDetailView(DetailView): +class OrderDetailView(LoginRequiredMixin, DetailView): model = Order template_name = 'dashboard/order_detail.html' @@ -93,10 +158,24 @@ class OrderDetailView(DetailView): return context -class OrderFulfillView(UpdateView): +class OrderFulfillView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Order template_name = "dashboard/order_fulfill.html" form_class = OrderLineFormset + success_message = "Order saved." + + def form_valid(self, form): + form.save() + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) + +class OrderTrackingView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Order + template_name = "dashboard/order_tracking_form.html" + form_class = OrderTrackingFormset + success_message = "Order saved." def form_valid(self, form): form.save() @@ -106,9 +185,10 @@ class OrderFulfillView(UpdateView): return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) -class ProductListView(ListView): +class ProductListView(LoginRequiredMixin, ListView): model = Product template_name = 'dashboard/product_list.html' + ordering = 'sorting' # def get_queryset(self): # object_list = Product.objects.filter( @@ -118,22 +198,63 @@ class ProductListView(ListView): # ) # return object_list -class ProductDetailView(DetailView): +class ProductDetailView(LoginRequiredMixin, DetailView): model = Product template_name = 'dashboard/product_detail.html' -class ProductUpdateView(UpdateView): +class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Product template_name = 'dashboard/product_update_form.html' fields = '__all__' + success_message = '%(name)s saved.' -class ProductCreateView(CreateView): +class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = Product - template_name = "dashboard/product_create_form.html" + template_name = 'dashboard/product_create_form.html' fields = '__all__' + success_message = '%(name)s created.' + +class ProductDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = Product + template_name = 'dashboard/product_confirm_delete.html' + success_url = reverse_lazy('dashboard:product-list') + success_message = 'Product deleted.' -class CustomerListView(ListView): +class ProductPhotoCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ProductPhoto + pk_url_kwarg = 'photo_pk' + template_name = 'dashboard/prodphoto_create_form.html' + form_class = ProductPhotoForm + success_message = 'Photo added.' + + 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 ProductPhotoDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = ProductPhoto + pk_url_kwarg = 'photo_pk' + template_name = 'dashboard/prodphoto_confirm_delete.html' + success_message = 'Photo deleted.' + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + + + + + +class CustomerListView(LoginRequiredMixin, ListView): model = User template_name = 'dashboard/customer_list.html' @@ -150,15 +271,16 @@ class CustomerListView(ListView): return object_list -class CustomerDetailView(DetailView): +class CustomerDetailView(LoginRequiredMixin, DetailView): model = User template_name = 'dashboard/customer_detail.html' context_object_name = 'customer' -class CustomerUpdateView(UpdateView): +class CustomerUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = User template_name = 'dashboard/customer_form.html' context_object_name = 'customer' + success_message = 'Customer saved.' fields = ( 'first_name', 'last_name', diff --git a/src/media/products/images/coffee.jpg b/src/media/products/images/coffee.jpg deleted file mode 100644 index 545e3d7..0000000 Binary files a/src/media/products/images/coffee.jpg and /dev/null differ diff --git a/src/media/products/images/coffee_02.jpg b/src/media/products/images/coffee_02.jpg deleted file mode 100644 index 1f55562..0000000 Binary files a/src/media/products/images/coffee_02.jpg and /dev/null differ diff --git a/src/media/products/images/coffee_05.jpg b/src/media/products/images/coffee_05.jpg deleted file mode 100644 index 2285280..0000000 Binary files a/src/media/products/images/coffee_05.jpg and /dev/null differ diff --git a/src/ptcoffee/config.py b/src/ptcoffee/config.py index 39df7e8..d6a6e0a 100644 --- a/src/ptcoffee/config.py +++ b/src/ptcoffee/config.py @@ -28,6 +28,7 @@ ANYMAIL_CONFIG = { SERVER_EMAIL = os.environ.get('SERVER_EMAIL', '') DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', '') +DEFAULT_CONTACT_EMAIL = os.environ.get('DEFAULT_CONTACT_EMAIL', '') SECURE_HSTS_SECONDS = os.environ.get('SECURE_HSTS_SECONDS', 3600) SECURE_SSL_REDIRECT = os.environ.get('SECURE_SSL_REDIRECT', 'False') == 'True' diff --git a/src/ptcoffee/settings.py b/src/ptcoffee/settings.py index aae9912..dd462df 100644 --- a/src/ptcoffee/settings.py +++ b/src/ptcoffee/settings.py @@ -9,6 +9,8 @@ BASE_DIR = Path(__file__).resolve().parent.parent # Add Your Required Allow Host if DEBUG == False: ALLOWED_HOSTS = ['ptcoffee-dev.windmillapps.org'] +else: + ALLOWED_HOSTS = ['192.168.68.106', '127.0.0.1', 'localhost'] INTERNAL_IPS = [ '127.0.0.1', diff --git a/src/static/images/fair_trade_stamp.png b/src/static/images/fair_trade_stamp.png index 9bc0f2e..d41c7dc 100644 Binary files a/src/static/images/fair_trade_stamp.png and b/src/static/images/fair_trade_stamp.png differ diff --git a/src/static/images/site_logo.svg b/src/static/images/site_logo.svg new file mode 100644 index 0000000..dc5c8d2 --- /dev/null +++ b/src/static/images/site_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/scripts/cookie.js b/src/static/scripts/cookie.js index fcc94bc..8e2378d 100644 --- a/src/static/scripts/cookie.js +++ b/src/static/scripts/cookie.js @@ -16,9 +16,9 @@ export function getCookie(name) { const twentyYears = 20 * 365 * 24 * 60 * 60 * 1000 -export function setCookie(name, value) { +export function setCookie(name, value, expiration=twentyYears) { const body = [ name, value ].map(encodeURIComponent).join("=") - const expires = new Date(Date.now() + twentyYears).toUTCString() + const expires = new Date(Date.now() + expiration).toUTCString() const cookie = `${body}; domain=; path=/; SameSite=Lax; expires=${expires}` document.cookie = cookie } diff --git a/src/static/scripts/index.js b/src/static/scripts/index.js new file mode 100644 index 0000000..e7820e6 --- /dev/null +++ b/src/static/scripts/index.js @@ -0,0 +1,37 @@ +import { getCookie, setCookie } from "./cookie.js" + +// Get the modal +const modal = document.querySelector(".modal-menu"); + +// Get the element that closes the modal +const closeBtn = document.querySelector(".close-modal"); + +const oneDay = 1 * 24 * 60 * 60 * 1000 + +// When the user clicks on (x), close the modal +closeBtn.addEventListener("click", event => { + modal.style.display = "none"; + setCookie('newsletter-modal', 'true', oneDay) +}) + +const scrollFunction = () => { + let modalDismissed = getCookie('newsletter-modal') + console.log(modalDismissed) + if (modalDismissed != 'true') { + if (document.body.scrollTop > 600 || document.documentElement.scrollTop > 600) { + modal.style.display = "block"; + } + } +} + +window.onscroll = () => { + scrollFunction(); +}; + +// When the user clicks anywhere outside of the modal, close it +window.addEventListener("click", event => { + if (event.target == modal) { + modal.style.display = "none"; + } + setCookie('newsletter-modal', 'true', oneDay) +}); diff --git a/src/static/scripts/payment.js b/src/static/scripts/payment.js index 1ba1e47..1c3345f 100644 --- a/src/static/scripts/payment.js +++ b/src/static/scripts/payment.js @@ -1,6 +1,6 @@ import { getCookie } from "./cookie.js" -let form = document.querySelector('form.order__form') +let form = document.querySelector('form') // Render the PayPal button into #paypal-button-container paypal.Buttons({ diff --git a/src/static/scripts/product_form.js b/src/static/scripts/product_form.js new file mode 100644 index 0000000..b740af1 --- /dev/null +++ b/src/static/scripts/product_form.js @@ -0,0 +1,14 @@ +const form = document.querySelector('form') +const purchaseTypeInput = form.querySelector('[name=purchase_type]') +const scheduleInput = form.querySelector('[name=schedule]') + +scheduleInput.parentElement.style.display = 'none' + +purchaseTypeInput.addEventListener('change', event => { + if (event.target.value === 'Subscribe') { + scheduleInput.parentElement.style.display = 'block' + } else if (event.target.value === 'One-time purchase') { + scheduleInput.parentElement.style.display = 'none' + } + +}) diff --git a/src/static/scripts/product_gallery.js b/src/static/scripts/product_gallery.js new file mode 100644 index 0000000..eecc3bd --- /dev/null +++ b/src/static/scripts/product_gallery.js @@ -0,0 +1,12 @@ +const gallery = document.querySelectorAll('.gallery__thumbnail') +const productImage = document.querySelector('.product__image') +let currentImg = document.querySelector('.gallery__thumbnail--focus') + +gallery.forEach(image => { + image.addEventListener('mouseover', event => { + currentImg.classList.remove('gallery__thumbnail--focus') + event.target.classList.add('gallery__thumbnail--focus') + currentImg = event.target + productImage.src = currentImg.src + }) +}) diff --git a/src/static/styles/dashboard.css b/src/static/styles/dashboard.css index 2942e86..1f35734 100644 --- a/src/static/styles/dashboard.css +++ b/src/static/styles/dashboard.css @@ -9,6 +9,7 @@ --red-color: #ff4d44; --default-border: 2px solid var(--gray-color); + --default-shadow: 0 1rem 3rem var(--gray-color); } html { @@ -71,6 +72,7 @@ label { input[type=text], input[type=email], input[type=number], +input[type=date], input[type=password], select[multiple=multiple], textarea { @@ -144,7 +146,7 @@ button:hover { } .action-button--warning { - background-color: var(--red-color); + background-color: var(--red-color) !important; } .action-link { @@ -261,6 +263,41 @@ main article { } +.site__messages { + text-align: left; + white-space: normal; + background-color: var(--fg-color); + border-radius: 0.5rem; + box-shadow: var(--default-shadow); + + margin: 1rem; + padding: 0.5rem 1rem; + + position: fixed; + + left: auto; + right: 0; + bottom: 0; + top: auto; + z-index: 990; +} + +.messages__message.debug { + color: white; +} +.messages__message.info { + color: white; +} +.messages__message.success { + color: var(--green-color); +} +.messages__message.warning { + color: var(--yellow-color); +} +.messages__message.error { + color: var(--red-color); +} + .object__header { display: flex; @@ -278,12 +315,33 @@ main article { .object__item { display: grid; - grid-template-columns: repeat(5, 1fr); gap: 1rem; padding: 1rem; border-bottom: 0.05rem solid var(--gray-color); text-decoration: none; align-items: center; + justify-items: start; +} + +.object__item--col3 { + grid-template-columns: repeat(3, 1fr); +} + +.object__item--col5 { + grid-template-columns: repeat(5, 1fr); +} + +.object__item--col4 { + grid-template-columns: repeat(4, 1fr); +} + +.object__item--col8 { + grid-template-columns: repeat(8, 1fr); +} + +.panel__header--flex { + display: flex; + justify-content: space-between; } .panel__item:last-child, @@ -292,11 +350,11 @@ main article { border-radius: 0 0 0.5rem 0.5rem; } -.object__item:hover { +.object__item--link:hover { background-color: var(--bg-alt-color); } -.object__item--header { +.panel__header { font-weight: bold; background-color: var(--bg-alt-color); border-radius: 0.5rem 0.5rem 0 0; @@ -328,6 +386,10 @@ main article { align-items: center; } +.object__menu > a:not(:last-child) { + margin-right: 1rem; +} + .order__fulfill { grid-column: 8; } @@ -356,7 +418,7 @@ main article { position: absolute; right: 1rem; border-radius: 0.5rem; - box-shadow: 0 0 3rem var(--gray-color); + box-shadow: var(--default-shadow); } .dropdown__child a { @@ -457,3 +519,24 @@ main article { height: 50px; margin-right: 1rem; } + +.gallery { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3rem; + +} + +.gallery__item { +} + +.gallery__item, +.gallery__item img { + width: 100%; +} + +.gallery__item img { + border: var(--default-border); + object-fit: cover; + aspect-ratio: 1/1; +} diff --git a/src/static/styles/main.css b/src/static/styles/main.css index 9142f15..30668be 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -1,9 +1,11 @@ :root { - --fg-color: #333; + --fg-color: #34201A; --bg-color: #f5f5f5; + --bg-alt-color: #eee5d3; --gray-color: #9d9d9d; --yellow-color: #f8a911; --yellow-alt-color: #ffce6f; + --yellow-dark-color: #b27606; --default-border: 2px solid var(--gray-color); } @@ -13,17 +15,22 @@ html { } body { - background: var(--bg-color); + background-color: var(--bg-color); font-family: 'Inter', sans-serif; font-weight: 400; - max-width: 1024px; - padding: 1rem; + padding: 0; margin: 0 auto; line-height: 1.75; color: var(--fg-color); } + +/* ========================================================================== + Typography + ========================================================================== */ + p { + margin-top: 0; margin-bottom: 1rem; } @@ -33,9 +40,9 @@ a { h1, h2, h3, h4, h5 { margin: 0; - font-family: 'Eczar', serif; font-weight: 700; line-height: 1.3; + margin-bottom: 1rem; } h1 { @@ -59,82 +66,102 @@ h5 { font-size: 1.2rem; } -small, .text_small { +small, .text-small { font-size: 0.833rem; } -label { +blockquote { + margin: 0 0 1rem; + padding: 0; +} + +blockquote q { + /*font-weight: bold;*/ + font-size: 1.5rem; +} +blockquote cite { display: block; } +.text-center { + text-align: center; +} - - - - -button, -input, -optgroup, -select, -textarea { - color: inherit; +/* ========================================================================== + Tables + ========================================================================== */ +table { + border-collapse: collapse; + border-spacing: 0; font: inherit; - margin: 0; - max-width: 100%; + width: 100%; + margin-bottom: 1rem; + border: 1px solid var(--gray-color); } -input { - outline: 0; -} - - - -label { +td, +th { + font: inherit; text-align: left; - font-weight: 700; + font-size: 0.85em; + padding: 2px 10px; + border-bottom: 1px solid; + border-color: var(--gray-color); } -select { - text-align: left; - border: var(--default-border); - padding: 0.5em; - outline: 0; +th { + font-weight: bold; } +table a { + white-space: normal; +} + + +/* ========================================================================== + Forms + ========================================================================== */ input[type=text], input[type=email], input[type=number], +input[type=date], input[type=password], -select[multiple=multiple], +select, textarea { - display: block; - text-align: left; color: var(--fg-color); border: var(--default-border); padding: 0.5rem; outline: 0; + box-sizing: border-box; } - input:focus, - textarea:focus { - border-color: var(--yellow-color); - } +input:focus, +textarea:focus { + border-color: var(--yellow-color); +} select[multiple=multiple] { - height: 125px; + height: 8rem; } +input[type=radio], input[type=checkbox] { - width: 1em; - vertical-align: text-top; + width: 2rem; + height: 2rem; + vertical-align: middle; +} + +input[type=radio] + label, +input[type=checkbox] + label { + display: inline-block; + margin: 1rem 0; } textarea { width: 100%; height: 6.25rem; - line-height: 1.45; } ::-webkit-input-placeholder { @@ -148,36 +175,36 @@ textarea { } -.action-button, +button, input[type=submit], -button { +.action-button { font-family: inherit; font-weight: bold; - font-size: 1rem; - color: var(--fg-color); + font-size: 1.5rem; text-decoration: none; text-transform: lowercase; font-variant: small-caps; + white-space: nowrap; + + color: var(--fg-color); background-color: var(--yellow-color); + padding: 0.25rem 1rem; border-radius: 0.2rem; border: none; + cursor: pointer; } +button, +input[type=submit]:hover, .action-button:hover { background-color: var(--yellow-alt-color); } -.action-button--large { - font-size: 2rem; -} -form input, -form select { - width: 100%; - box-sizing: border-box; -} + + @@ -188,237 +215,515 @@ figure { } img { box-sizing: border-box; -} - -.product__item { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 0 2rem; -} - -.product__list-item button { - grid-column: 1/3; - align-self: end; -} - - - -.product__image { - /*object-fit: cover;*/ max-width: 100%; } -.product__form { - display: grid; - grid-template-columns: 1fr 3fr; - gap: 1rem; + +/* ========================================================================== + Base Layout + ========================================================================== */ +.site__header > nav, +main > article, +footer > section { + max-width: 1024px; + padding: 1rem; + margin: 0 auto; } -.product__form input[type=submit] { - grid-column: 2; - justify-self: end; + +/* ========================================================================== + Modal + ========================================================================== */ +.modal-menu { + display: none; + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ } -.site__logo { - text-decoration: none; +/* Modal Content/Box */ +.modal-menu__content { + background-color: var(--bg-color); + margin: 25vh auto; + padding: 20px; + border: var(--default-border); + max-width: 40rem; } -.site__header div, -.site__header nav { + +.modal-menu__header { display: flex; justify-content: space-between; align-items: baseline; } -nav a { - text-transform: lowercase; - font-variant: small-caps; +/* The Close Button */ +.close-modal { + color: #aaa; + font-size: 2rem; + line-height: 0; font-weight: bold; - text-decoration: none; } -nav { - margin-bottom: 4rem; -} - -.site__copyright { - text-align: center; -} - -.keep_calm { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 2rem; -} -.keep_calm__img { - max-width: 250px; -} - - -.site__cart { - display: flex; - align-items: center; - background-color: var(--yellow-color); - padding: 0.25rem 1rem; - text-decoration: none; - border-radius: 0.2rem; +.close-modal:hover, +.close-modal:focus { color: var(--fg-color); + text-decoration: none; cursor: pointer; } +/* ========================================================================== + Site + ========================================================================== */ +.site__ft-stamp { + max-width: 6rem; +} + +/* Site Header + ========================================================================== */ +.site__header { + background-color: var(--bg-alt-color); +} + +.site__logo > img { + height: 4rem; +} + +/* Site Nav + ========================================================================== */ + +.site__nav { + display: flex; + justify-content: space-between; + align-items: center; +} + +.site__logo, +.nav__main, +.nav__account { + margin-right: 1rem; +} + +@media screen and (max-width: 900px) { + .site__logo, + .nav__main, + .nav__account { + margin-right: 0; + } + .site__nav { + display: grid; + grid-template-columns: 2fr 0.5fr 0.5fr; + gap: 1rem; + } + .site__logo { + grid-column: 1; + } + .nav__main { + grid-column: 1/4; + grid-row: 2; + justify-content: space-between; + } + .nav__account { + grid-column: 2; + grid-row: 1; + } + .site__cart { + grid-column: 3; + grid-row: 1; + } +} + +@media screen and (max-width: 400px) { + .site__nav { + grid-template-columns: repeat(2, 1fr); + } + .site__logo { + grid-column: span 2; + justify-self: center; + } + .nav__main { + grid-column: 1/3; + grid-row: 3; + } + .nav__account { + grid-column: 1; + grid-row: 2; + } + .site__cart { + grid-column: 2; + grid-row: 2; + } +} + +.nav__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; +} + +.nav__menu { + position: relative; +} + +.nav__link { + text-transform: lowercase; + font-variant: small-caps; + font-weight: bold; + text-decoration: none; + white-space: nowrap; +} + +.nav__list li:not(:last-child) { + margin-right: 1rem; +} + +.nav__list.menu { + flex-direction: column; +} + +.nav__dropdown { + list-style: none; + margin: 0; + padding: 0; + background-color: var(--bg-color); + border: var(--default-border); + padding: 0.5rem; + position: absolute; + top: 100%; + right: 0; + z-index: 1000; + display: none; +} + +@media screen and (max-width: 400px) { + .nav__dropdown { + left: 0; + right: unset; + } +} + +.nav__menu:hover .nav__dropdown { + display: flex; + flex-direction: column; +} + +/* Site Cart + ========================================================================== */ +.site__cart { + display: flex; + justify-content: center; + align-items: center; + + justify-self: end; + + font-family: inherit; + font-weight: bold; + text-decoration: none; + text-transform: lowercase; + font-variant: small-caps; + + color: var(--fg-color); + background-color: var(--yellow-color); + padding: 0.25rem 1rem; + border-radius: 0.2rem; + cursor: pointer; +} + +.cart__count { + font-size: 1rem; +} + .cart__icon { - width: 2rem; height: 2rem; } -.cart__length { - font-size: 1.5rem; - font-weight: bold; - font-family: 'Inter', sans-serif; + +/* ========================================================================== + Articles + ========================================================================== */ +article > header { + margin-bottom: 1rem; } -.cart__item { +.article__header--with-action { + display: flex; + justify-content: space-between; + align-items: center; +} + +article + article { + margin-top: 8rem; +} + +/* Product reviews + ========================================================================== */ +.review__list { display: grid; - grid-template-columns: 1fr 5fr; - gap: 1rem; - padding: 2rem 0; - border-bottom: 0.05rem solid var(--gray-color); + grid-template-columns: repeat(2, 1fr); + gap: 4rem; } -.cart__total_price { +.review__list-short { + margin: inherit auto; + max-width: 64ch; + text-align: center; +} + +.review__item { + text-align: center; +} + +@media screen and (max-width: 600px) { + .review__list { + grid-template-columns: 1fr; + } +} + +/* Products + ========================================================================== */ +.product__list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4rem; +} + +.product__item { + text-decoration: none; + + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.product__item h3 { + text-decoration: underline; +} + +@media screen and (max-width: 900px) { + .product__list { + grid-template-columns: 1fr; + gap: 6rem; + } + .product__figure { + grid-row: span 2; + } +} + +@media screen and (max-width: 600px) { + .product__item { + grid-template-columns: 1fr; + } + + .product__figure { + grid-row: 1; + justify-self: center; + } +} + + +/* Product Detail + ========================================================================== */ +.product__detail { + display: grid; + grid-template-columns: 0.25fr 2fr 1fr; + gap: 1rem; +} + +.gallery__thumbnail { + border: var(--default-border); + border-color: var(--bg-color); + cursor: pointer; + object-fit: cover; + aspect-ratio: 1/1; +} + +.gallery__image { + text-align: center; +} + +.gallery__thumbnail--focus { + border-color: var(--yellow-color); +} + +.product__form input, +.product__form select { + width: 100%; +} + +@media screen and (max-width: 700px) { + .product__detail { + grid-template-columns: 0.25fr 2fr; + } + + .product__info { + grid-column: span 2; + } +} + +@media screen and (max-width: 700px) { + .product__detail { + grid-template-columns: 1fr; + grid-template-rows: 4rem 1fr auto; + } + .product__gallery { + display: flex; + } + .gallery__thumbnail { + max-height: 4rem; + } + .gallery__thumbnail:not(:last-child) { + margin-right: 0.5rem; + } + .product__image { + max-height: 16rem; + } + .product__info { + grid-column: 1; + } +} + + +/* Shopping Cart + ========================================================================== */ +.cart__list { + margin-bottom: 2rem; +} +.cart__item { + padding: 1rem 0; + border-bottom: var(--default-border); + display: grid; + grid-template-columns: 1fr 3fr 1fr; + gap: 1rem; +} + +.cart__table-wrapper { + display: flex; + justify-content: flex-end; +} + +.cart__summary { text-align: right; +} +.cart__totals { + width: unset; font-size: 1.5rem; } -.cart__total { + +.cart__totals th, +.cart__totals td { + border: none; +} +.cart__totals th:first-child, +.cart__totals td:first-child { + text-align: right; + +} + +.cart__proceed { + text-align: right; display: flex; align-items: center; justify-content: flex-end; } -.item__figure img { - vertical-align: middle; +.cart__proceed > .action-button { + font-size: 1.75rem; +} + +.item__image { + max-height: 12rem; +} + +.item__price { + justify-self: end; +} + +.item__form p, +.coupon__form p { + display: flex; + align-items: center; +} + +.coupon__form p { + justify-content: flex-end; +} + +.item__form input[type=number] { + max-width: 6rem; +} + +.coupon__form input[type=text] { + max-width: 8rem; +} + +:is(.item__form, .coupon__form) label, +:is(.item__form, .coupon__form) input:not(:last-child) { + margin-right: 0.5rem; +} + +@media screen and (max-width: 500px) { + .cart__item { + grid-template-columns: 2fr 1fr; + } + + .cart__proceed { + flex-direction: column; + } + + .item__info { + grid-column: span 2; + grid-row: 2; + } + + .item__price { + grid-column: 2; + grid-row: 1; + } } -.product__list { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8rem; + +/* Checkout / Shipping Address + ========================================================================== */ +.checkout__address-form input, +.checkout__address-form select { + display: block; + width: 100%; + max-width: 24rem; } -.product__list-item { - text-decoration: none; - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; +.checkout__address { + margin-bottom: 2rem; } -.product__list-item form { - grid-column: 1/3; -} - -.product__list-item figure { - margin: 0; - padding: 0; +#paypal-button-container { + max-width: 24rem; + margin-left: auto; } -.order__shipping { - margin-bottom: 3rem; -} -.order__total { - margin: 3rem 0; - text-align: right; -} - -.order__details { - /*margin: 3rem 0;*/ - -} +/* ========================================================================== + Footer + ========================================================================== */ footer { margin: 4rem 0 0; box-sizing: border-box; - border-top: var(--default-border); - padding: 2rem 0; -} - - - - - - - -.object__header { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: 1rem; -} - -.object__list, -.object__panel { - background-color: white; - border-radius: 0.5rem; - margin-bottom: 2rem; -} - -.object__item { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 1rem; - padding: 1rem; - border-bottom: 0.05rem solid var(--gray-color); - text-decoration: none; - align-items: center; -} - -.panel__item:last-child, -.object__item:last-child { - border-bottom: unset; - border-radius: 0 0 0.5rem 0.5rem; -} - -.object__item:hover { + padding: 1rem 0; background-color: var(--bg-alt-color); } -.object__item--header { - font-weight: bold; - background-color: var(--bg-alt-color); - border-radius: 0.5rem 0.5rem 0 0; -} - -.panel__item { - padding: 1rem; - border-bottom: 0.05rem solid var(--gray-color); - text-decoration: none; -} - - -.product__detail { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 2rem; -} - - -.user__emails { - margin-bottom: 4rem; -} - - - - - - -._form_1 div { - text-align: left !important; -} -._form_1 div form { - margin: 1rem 0 !important; - padding: 0 !important; +footer > section { + text-align: center; + } diff --git a/src/static/styles/normalize.css b/src/static/styles/normalize.css new file mode 100644 index 0000000..192eb9c --- /dev/null +++ b/src/static/styles/normalize.css @@ -0,0 +1,349 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/src/storefront/cart.py b/src/storefront/cart.py index c9827e7..906e10e 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -1,31 +1,37 @@ import logging from decimal import Decimal from django.conf import settings -from core.models import Product, OrderLine +from core.models import Product, OrderLine, Coupon from .payments import CreateOrder +from core import ( + DiscountValueType, + VoucherType, + TransactionStatus, + OrderStatus, + ShippingMethodType +) + logger = logging.getLogger(__name__) class Cart: def __init__(self, request): self.session = request.session + self.coupon_code = self.session.get('coupon_code') cart = self.session.get(settings.CART_SESSION_ID) if not cart: cart = self.session[settings.CART_SESSION_ID] = {} self.cart = cart - def add(self, product, quantity=1, roast='', update_quantity=False): + def add(self, product, quantity=1, grind='', update_quantity=False): product_id = str(product.id) if product_id not in self.cart: self.cart[product_id] = { 'quantity': 0, - 'roast': roast, + 'grind': grind, 'price': str(product.price) } - elif product_id in self.cart: - self.cart[product_id].update({ - 'roast': roast, - }) + if update_quantity: self.cart[product_id]['quantity'] = quantity else: @@ -62,14 +68,16 @@ class Cart: def clear(self): del self.session[settings.CART_SESSION_ID] + del self.session['coupon_code'] self.session.modified = True def build_order_params(self): return \ { 'items': self, - 'total_price': f'{self.get_total_price()}', + 'total_price': f'{self.get_total_price_after_discount()}', 'item_total': f'{self.get_total_price()}', + 'discount': f'{self.get_discount()}', 'shipping_price': '0', 'tax_total': '0', 'shipping_method': 'US POSTAL SERVICE', @@ -86,7 +94,7 @@ class Cart: bulk_list = [OrderLine( order=order, product=item['product'], - customer_note=item['roast'], + customer_note=item['grind'], unit_price=item['price'], quantity=item['quantity'], tax_rate=2, @@ -105,16 +113,19 @@ class Cart: 'country_code': 'US' } - # @property - # def coupon(self): - # if self.coupon_id: - # return Coupon.objects.get(id=self.coupon_id) - # return None + @property + def coupon(self): + if self.coupon_code: + return Coupon.objects.get(code=self.coupon_code) + return None - # def get_discount(self): - # if self.coupon: - # return (self.coupon.discount / Decimal('100')) * self.get_total_price() - # return Decimal('0') + def get_discount(self): + 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) + return Decimal('0') - # def get_total_price_after_discount(self): - # return self.get_total_price() - self.get_discount() + def get_total_price_after_discount(self): + return round(self.get_total_price() - self.get_discount(), 2) diff --git a/src/storefront/forms.py b/src/storefront/forms.py index ec4df2f..b962d6a 100644 --- a/src/storefront/forms.py +++ b/src/storefront/forms.py @@ -5,6 +5,8 @@ from django.core.mail import EmailMessage from core.models import Order from accounts import STATE_CHOICES +from .tasks import contact_form_email + logger = logging.getLogger(__name__) class AddToCartForm(forms.Form): @@ -17,7 +19,7 @@ class AddToCartForm(forms.Form): AEROPRESS = 'AeroPress' PERCOLATOR = 'Percolator' OTHER = 'Other' - ROAST_CHOICES = [ + GRIND_CHOICES = [ (WHOLE, 'Whole Beans'), (ESPRESSO, 'Espresso'), (CONE_DRIP, 'Cone Drip'), @@ -28,9 +30,49 @@ class AddToCartForm(forms.Form): (PERCOLATOR, 'Percolator'), (OTHER, 'Other (enter below)') ] + + grind = forms.ChoiceField(choices=GRIND_CHOICES) + quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) + +class UpdateCartItemForm(forms.Form): + quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) + update = forms.BooleanField(required=False, initial=True, widget=forms.HiddenInput) + + +class AddToSubscriptionForm(forms.Form): + WHOLE = 'Whole Beans' + ESPRESSO = 'Espresso' + CONE_DRIP = 'Cone Drip' + BASKET_DRIP = 'Basket Drip' + FRENCH_PRESS = 'French Press' + STOVETOP_ESPRESSO = 'Stovetop Espresso (Moka Pot)' + AEROPRESS = 'AeroPress' + PERCOLATOR = 'Percolator' + OTHER = 'Other' + GRIND_CHOICES = [ + (WHOLE, 'Whole Beans'), + (ESPRESSO, 'Espresso'), + (CONE_DRIP, 'Cone Drip'), + (BASKET_DRIP, 'Basket Drip'), + (FRENCH_PRESS, 'French Press'), + (STOVETOP_ESPRESSO, 'Stovetop Espresso (Moka Pot)'), + (AEROPRESS, 'AeroPress'), + (PERCOLATOR, 'Percolator'), + (OTHER, 'Other (enter below)') + ] + + 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'), + ] + quantity = forms.IntegerField(min_value=1, initial=1) - roast = forms.ChoiceField(choices=ROAST_CHOICES) - update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput) + grind = forms.ChoiceField(choices=GRIND_CHOICES) + schedule = forms.ChoiceField(choices=SCHEDULE_CHOICES) class AddressForm(forms.Form): @@ -53,8 +95,43 @@ class OrderCreateForm(forms.ModelForm): class Meta: model = Order fields = ( + 'coupon', 'total_net_amount', ) widgets = { + 'coupon': forms.HiddenInput(), 'total_net_amount': 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 in the Message section below'), + ] + + first_name = forms.CharField() + last_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) + + def send_email(self): + contact_form_email.delay(self.cleaned_data) diff --git a/src/storefront/payments.py b/src/storefront/payments.py index 1951d4e..2a7bc5a 100644 --- a/src/storefront/payments.py +++ b/src/storefront/payments.py @@ -112,10 +112,10 @@ class CreateOrder(PayPalClient): "currency_code": "USD", "value": params['tax_total'] }, - # "shipping_discount": { - # "currency_code": "USD", - # "value": "10" - # } + "discount": { + "currency_code": "USD", + "value": params['discount'] + } } }, "items": processed_items, diff --git a/src/storefront/tasks.py b/src/storefront/tasks.py index e02e9db..dadbf7f 100644 --- a/src/storefront/tasks.py +++ b/src/storefront/tasks.py @@ -1,26 +1,22 @@ -# from celery import shared_task -# from celery.utils.log import get_task_logger -# from django.conf import settings -# from django.core.mail import EmailMessage, send_mail +from celery import shared_task +from celery.utils.log import get_task_logger +from django.conf import settings +from django.core.mail import EmailMessage, send_mail -# from templated_email import send_templated_mail +from templated_email import send_templated_mail -# from core.models import Order - -# logger = get_task_logger(__name__) +logger = get_task_logger(__name__) -# CONFIRM_ORDER_TEMPLATE = 'storefront/order_confirmation' -# ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' -# ORDER_REFUND_TEMPLATE = 'storefront/order_refund' +COTACT_FORM_TEMPLATE = 'storefront/contact_form' -# @shared_task(name='send_order_confirmation_email') -# def send_order_confirmation_email(order): -# send_templated_mail( -# template_name=CONFIRM_ORDER_TEMPLATE, -# from_email=settings.DEFAULT_FROM_EMAIL, -# recipient_list=[order['email']], -# context=order -# ) +@shared_task(name='contact_form_email') +def contact_form_email(formdata): + send_templated_mail( + template_name=COTACT_FORM_TEMPLATE, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[settings.DEFAULT_CONTACT_EMAIL], + context=formdata + ) -# logger.info(f"Order confirmation email sent to {order['email']}") + logger.info(f"Contact form email sent from {formdata['email_address']}") diff --git a/src/storefront/templates/storefront/about.html b/src/storefront/templates/storefront/about.html index 6a01753..eb35732 100644 --- a/src/storefront/templates/storefront/about.html +++ b/src/storefront/templates/storefront/about.html @@ -3,10 +3,14 @@ {% block content %}
-

About PT Coffee

-
- -
+
+

About PT Coffee

+
+
+
+ +
+

We love coffee!

If you’ve found Port Townsend Coffee Roasting Co., you probably love coffee so much that you seek out the best tasting, Certified Fair Trade Organic coffees available.

@@ -27,7 +31,7 @@

We roast our coffee much more slowly than most roasters, especially coffee roasters in the Pacific Northwest. The slower roasting process allows for greater bean development. The bean is evenly roasted right to the center and our process mutes some of the acidic compounds, which smooths out the flavor.

Port Townsend Coffee is known for roasts that are darker than others available in the Northwest. Due to our roasting process, which emphasizes patience with the beans as well as air flow adjustments, the darker roasts are smooth and syrupy. Dark roasts from other companies can taste bitter, or slightly burnt due to the size and speed of the roast.

We have been buying beans from the same brokers for many years. They understand the flavor profiles we prefer and seek to accommodate our needs.

-
+
Fair Trade and Organic

We pay a steep premium for these beans, which are typically from smaller farms that are organized into co-ops. These farms take pride in their coffees, as the farmers make a living wage and their families are able to live in a healthier, more secure environment than farmers who grow a conventional coffee crop. The quality of our coffee is consistent, in part due to the quality of organic and fair trade beans.

Freshness and Storage
diff --git a/src/storefront/templates/storefront/address_form.html b/src/storefront/templates/storefront/address_form.html new file mode 100644 index 0000000..acec0f3 --- /dev/null +++ b/src/storefront/templates/storefront/address_form.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

← Back

+

Update Address

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

+ +

+
+
+
+{% endblock %} diff --git a/src/storefront/templates/storefront/cart_detail.html b/src/storefront/templates/storefront/cart_detail.html index 5e962d4..60cd87c 100644 --- a/src/storefront/templates/storefront/cart_detail.html +++ b/src/storefront/templates/storefront/cart_detail.html @@ -2,36 +2,73 @@ {% block content %}
-

Shopping Cart

-
+
+

Shopping Cart

+
+
{% for item in cart %}
{% with product=item.product %}
- {{product.productphoto_set.first.image}} + {{product.productphoto_set.first.image}}
-
-

{{product.name}}
${{item.price}}

+
+

{{product.name}}

+

Grind: {{item.grind}}

+
+ {% csrf_token %} +

+ {{ item.update_quantity_form }} + +

+

Remove from cart

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

${{item.price}}

{% endwith %}
{% empty %} -

No items in cart yet.

+
+

No items in cart yet.

+
{% endfor %}
-
-

Cart total: ${{cart.get_total_price}}

-

- Continue Shopping or Proceed to Checkout -

+
+

Cart Totals

+
+
+ {% csrf_token %} +

+ {{ coupon_apply_form }} + +

+
+
+ + + + + + {% if cart.coupon %} + + + + + {% endif %} + + + + +
Subtotal${{cart.get_total_price|floatformat:"2"}}
Coupon{{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}
Total${{cart.get_total_price_after_discount|floatformat:"2"}}
+
+

+ Continue Shopping{% if cart|length > 0 %} or Proceed to Checkout{% endif %} +

+
{% endblock %} diff --git a/src/storefront/templates/storefront/checkout_address.html b/src/storefront/templates/storefront/checkout_address.html index d70373e..2a05980 100644 --- a/src/storefront/templates/storefront/checkout_address.html +++ b/src/storefront/templates/storefront/checkout_address.html @@ -3,16 +3,18 @@ {% block content %}
-

Checkout

-
-
-

Shipping Address

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

Checkout

+
+
+

Shipping Address

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

-

-
+

+
{% endblock %} diff --git a/src/storefront/templates/storefront/checkout_shipping.html b/src/storefront/templates/storefront/checkout_shipping.html deleted file mode 100644 index f529cf6..0000000 --- a/src/storefront/templates/storefront/checkout_shipping.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} -
-

Checkout

-
-
-

Shipping Method

-
- {{form.as_p}} - -
-
-
-
-{% endblock %} diff --git a/src/storefront/templates/storefront/contact_form.html b/src/storefront/templates/storefront/contact_form.html new file mode 100644 index 0000000..a0faae2 --- /dev/null +++ b/src/storefront/templates/storefront/contact_form.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Contact us

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

+ +

+
+
+
+{% endblock %} diff --git a/src/storefront/templates/storefront/customer_detail.html b/src/storefront/templates/storefront/customer_detail.html index 289a626..07a25e2 100644 --- a/src/storefront/templates/storefront/customer_detail.html +++ b/src/storefront/templates/storefront/customer_detail.html @@ -3,21 +3,19 @@ {% block content %}
-
+

{{customer.get_full_name}}

Edit profile
-
-
-

Info

-
-
+
+

Info

+

Email address
{{customer.email}}
Manage -

-
- Default shipping address
+

+

+ Default shipping address {% with shipping_address=customer.default_shipping_address %}

{{shipping_address.first_name}} @@ -28,11 +26,10 @@ {% endif %} {{shipping_address.city}}, {{shipping_address.state}}, {{shipping_address.postal_code}}
- Edit {% endwith %} -
-
- Other addresses
+

+
+

All addresses

{% for address in customer.addresses.all %}

@@ -44,34 +41,38 @@ {% endif %} {{address.city}}, {{address.state}}, {{address.postal_code}}
- Edit + Edit

{% empty %}

No other addresses.

{% endfor %}
- {% with order_list=customer.orders.all %} -
-
- Order # - Date - Status - Total -
- {% for order in order_list %} - - #{{order.pk}} - {{order.created_at|date:"D, M j Y"}} - -
- {{order.get_status_display}}
- ${{order.total_net_amount}} -
- {% empty %} - No orders - {% endfor %} -
- {% endwith %} +{% with order_list=customer.orders.all %} +
+

Your orders

+ + + + + + + + + + {% for order in order_list %} + + + + + + + {% empty %} + No orders + {% endfor %} + +
Order #DateTotal
#{{order.pk}}{{order.created_at|date:"M j, Y"}}${{order.total_net_amount}}See details →
+
+{% endwith %}
{% endblock content %} diff --git a/src/storefront/templates/storefront/customer_form.html b/src/storefront/templates/storefront/customer_form.html index 4ef2901..db37b6c 100644 --- a/src/storefront/templates/storefront/customer_form.html +++ b/src/storefront/templates/storefront/customer_form.html @@ -1,14 +1,17 @@ {% extends "base.html" %} {% block content %} -
-

Update your profile

+
+
+

← Back

+

Update your profile

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

- or cancel +

+

diff --git a/src/storefront/templates/storefront/order_detail.html b/src/storefront/templates/storefront/order_detail.html new file mode 100644 index 0000000..195e5ec --- /dev/null +++ b/src/storefront/templates/storefront/order_detail.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+

← Back

+
+

Order #{{order.pk}}

+

Placed on {{order.created_at|date:"M j, Y"}}

+
+
+ + + + + + + + + + + {% for item in order.lines.all %} + + {% with product=item.product %} + + + + + + {% endwith %} + + {% empty %} + + + + {% endfor %} + +
ProductQuantityPriceTotal
+ {{product.productphoto_set.first.image}} + {{product.name}}{{item.quantity}}${{product.price}}${{item.get_total}}
No items in order
+
+ +
+

Payment

+ + + + + + {% if order.coupon %} + + + + + {% endif %} + + + + +
Subtotal${{order.total_net_amount}}
Discount{{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}
Total${{order.get_total_price_after_discount}}
+
+
+{% endblock content %} diff --git a/src/storefront/templates/storefront/order_form.html b/src/storefront/templates/storefront/order_form.html index d648d57..d4e46d4 100644 --- a/src/storefront/templates/storefront/order_form.html +++ b/src/storefront/templates/storefront/order_form.html @@ -2,53 +2,73 @@ {% load static %} {% block head %} - + {% endblock %} {% block content %}
-

Checkout

-
-
-

Shipping Address

-
- {{shipping_address.first_name}} - {{shipping_address.last_name}}
- {{shipping_address.street_address_1}}
- {% if shipping_address.street_address_2 %} - {{shipping_address.street_address_2}}
- {% endif %} - {{shipping_address.city}}, {{shipping_address.state}}, {{shipping_address.postal_code}} -
- edit -
-
-

Cart Summary

- {% for item in cart %} -
- {% with product=item.product %} -
- {{item.quantity}} x - {{product.productphoto_set.first.image}} -
-
-

{{product.name}}
${{item.price}}

-

Grind options: {{item.customer_note}}

-
- {% endwith %} +
+

Checkout

+
+
+

Shipping address

+
+ {{shipping_address.first_name}} + {{shipping_address.last_name}}
+ {{shipping_address.street_address_1}}
+ {% if shipping_address.street_address_2 %} + {{shipping_address.street_address_2}}
+ {% endif %} + {{shipping_address.city}}, {{shipping_address.state}}, {{shipping_address.postal_code}} +
+ Change +
+
+

Review items

+ {% for item in cart %} +
+ {% with product=item.product %} +
+ {{product.productphoto_set.first.image}} +
+
+

{{product.name}}

+

Grind options: {{item.grind}}

+

Quantity: {{item.quantity}}

- {% endfor %} +
+

${{item.price}}

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

Total: ${{cart.get_total_price}}

-
+ {% endfor %} +
+
+

Order summary

+
+ {% csrf_token %} + {{form.as_p}} +
+
+ + + + + + {% if cart.coupon %} + + + + + {% endif %} + + + + +
Subtotal${{cart.get_total_price|floatformat:"2"}}
Coupon{{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}
Total${{cart.get_total_price_after_discount|floatformat:"2"}}
+
{% endblock %} diff --git a/src/storefront/templates/storefront/product_detail.html b/src/storefront/templates/storefront/product_detail.html index 0b84bde..611c939 100644 --- a/src/storefront/templates/storefront/product_detail.html +++ b/src/storefront/templates/storefront/product_detail.html @@ -1,24 +1,33 @@ {% extends "base.html" %} +{% load static %} + +{% block head %} + +{% endblock %} {% block content %} - -
-
- {{product.productphoto_set.first.image}} +
+ + -
+

{{product.name}}

{{product.description}}

+

${{product.price}}

{{product.weight.oz}} oz

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

- +

- {% endblock %} diff --git a/src/storefront/templates/storefront/product_list.html b/src/storefront/templates/storefront/product_list.html index 69a11dc..87fc395 100644 --- a/src/storefront/templates/storefront/product_list.html +++ b/src/storefront/templates/storefront/product_list.html @@ -2,24 +2,54 @@ {% block content %} +
+
+

What people are saying

+
+
+
+ Really good coffee. That's all there is to say. Supposedly the pour over coffee is the best way to go. It is definitely nothing like a Starbucks, and in this case, that's a very good thing! + -Mark Nickel +
+ +
+ They have a passion for their coffee and chai. What makes them interesting is the intensity of flavor and not the extreme sweetness that other coffee shops focus on. The mocha had the velvety bitterness of true cocoa and the chai was more reminiscent of walking through an Indian spice store. These are drinks that should be sipped while sitting quietly at one of their picnic tables looking out at the water. + -Natasha Hughes +
+ +
+ Morning started with a Caffè Arancia and a scone. Couldn't have gotten anything better. Superb flavor for the coffee, a delight to drink. + -Mr Ty +
+ +
+ Warm and welcoming place with the best Americano I’ve ever had. + -Mathew Metcalfe +
+
+
+

+ Read more → +

+
+
{% endblock %} -{% block footer %} -
-{% endblock footer %} diff --git a/src/storefront/templates/storefront/reviews.html b/src/storefront/templates/storefront/reviews.html new file mode 100644 index 0000000..d794e5a --- /dev/null +++ b/src/storefront/templates/storefront/reviews.html @@ -0,0 +1,771 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Reviews

+
+
+
+ Really good coffee. That's all there is to say. Supposedly the pour over coffee is the best way to go. It is definitely nothing like a Starbucks, and in this case, that's a very good thing! + -Mark Nickel +
+ +
+ They have a passion for their coffee and chai. What makes them interesting is the intensity of flavor and not the extreme sweetness that other coffee shops focus on. The mocha had the velvety bitterness of true cocoa and the chai was more reminiscent of walking through an Indian spice store. These are drinks that should be sipped while sitting quietly at one of their picnic tables looking out at the water. + -Natasha Hughes +
+ +
+ Morning started with a Caffè Arancia and a scone. Couldn't have gotten anything better. Superb flavor for the coffee, a delight to drink. + -Mr Ty +
+ +
+ Warm and welcoming place with the best Americano I’ve ever had. + -Mathew Metcalfe +
+ +
+ Go to this coffee shop if you want great coffee and a pleasant atmosphere. The caramel latte I got handed had a great consistency, perfect amount of foam, and excellent taste. The staff know how to make good coffee. + -Nate Sullivan +
+ +
+ The absolute best coffee house in town! Don't worry about the line, the wait is well worth it. + -Brian +
+ +
+ This is my first stop every time I go through town. The coffee is amazing! The food is amazing! The people are amazing! Seriously wish we had a sister shop over in Seattle. + -Steve Cragar +
+ +
+ We've tried four different coffee shops in Port Townsend and this one is simply the best of those we've visited. Sometimes, there's a bit of a line but that's an indication of a popular spot and it's well worth the wait. Definitely would recommend you try them if you've never been. + -William Jones +
+ +
+ If I did nothing else in Port Townsend except visit this coffee shop, it would have been worth the trip. By far one of the best lattes I've ever had, even with non-dairy milk + -Keera Lindenberg +
+ +
+ The espresso was so good, we kept coming back and bought a bag of beans. + -Brett Melton +
+ +
+ This coffee shop is BY FAR the best coffee shop I have ever been to - and I have been to coffee shops around the world. Inexpensive, organic and absolutely delicious espresso drinks + -karolina anderson +
+ +
+ I don’t get out to Port Townsend often but every time I do I make a point to stop at Better Living Through Coffee. Their Caffè Arancia is one of the best coffee beverages I’ve ever had. + -Brenton Woodrow +
+ +
+ This was the best cup of coffee I've ever had. I go for Americano every time. It is truly about the coffee. + -Jeri Baird +
+ +
+ Stopped by on my way to oyster run last year and it was the best coffee experience ive ever had. It was an amazing cup of drip coffee + -james williams +
+ +
+ One of those hidden local gems that serve the best coffee + -michael peterson +
+ +
+ This place has the best iced coffee I have ever had. + -Troy Goracke +
+ +
+ One of the best coffee shops in the country! + -Brian Rohr +
+ +
+ The x4 shot iced coffee will forever leave me chasing the dragon. It was richly coffee flavored with out being bitter ... unmatched the world over. I'll be back every time I visit Washington! + -Conner Morlang +
+ +
+ When visiting Port Townsend, this is THE place to get coffee. The coffee is outstanding and offered in a variety of ways. + -kathleen b +
+ +
+ Amazing coffee at prices lower than their competitors. + -Monique Ermine +
+ +
+ Just the best dang pour over coffee + -Jonquil Elise Dreadful +
+ +
+ The best coffee very tasty. + -Audy Vasquez-Ramirez +
+ +
+ Outstanding Coffee service and ambiance. + -Mimi Magoo +
+ +
+ A... MAZ...ING! Dantes coffee pour over is stellar. + -Jeremy Bryant +
+ +
+ Excellent coffee and service. I drove 90 miles for Sofia's Sumatra. + -Ian Roberts +
+ +
+ Best coffee in Port Townsend. + -Anna Stenwick +
+ +
+ Great view and outside seating area. Plus the coffee was amazing and top notch service. + -Laura McKinney +
+ +
+ Delicious coffee. Fresh, local, organic ingredients. So nice to sit and enjoy the waterfront view. + -R_ V_B +
+ +
+ I had one of the best mugs of pour over coffee I've ever tasted. + -Ken Hoekema +
+ +
+ A must stop in Port Townsend!! The coffee drink was amazing + -Patty Glenn +
+ +
+ Really great cup of espresso! + -Vicky G +
+ +
+ They really make good coffee. Quality beans for sure. + -Jason Everett +
+ +
+ Pour over specialty here. 10/10 on coffee + -Olive Cattau +
+ +
+ Seriously good coffee, comfortable welcoming atmosphere + -Eric Mager +
+ +
+ Excellent coffee delivered by friendly people in a cool environment + -Mr T +
+ +
+ This has ended up the place we end up going to every trip to Port Townsend. Awesome coffees and drinks, + -Sean McNulty +
+ +
+ Beautiful view, amazing coffee and the friendliest staff! Highly recommended + -SCH Travel +
+ +
+ Very hip, warm atmosphere, great views and wonderful coffee. + -Toke Beard +
+ +
+ Funky, friendly atmosphere and a great cup of coffee, freshly dripped right into your cup. + -Mike Cutcliff +
+ +
+ Delicious coffee. Love this place! + -Jenny Mitchell +
+ +
+ Best orange espresso (Arancia)! Also, a calm atmosphere and competent staff. + -Deanne McCausland +
+ +
+ Great service, awesome coffee.. low key always a favorite.. + -E Quigley +
+ +
+ Amazing place..the coffee drinks are so delicious. Great staff. + -Angela Dawn +
+ +
+ They are very friendly and make the best cafe arancia ever. Yumm + -Vicki Moler +
+ +
+ An absolutely delightful spot to visit. Nice views and a casual unpretentious vibe, + -Kevin Chung +
+ +
+ Great place! Great coffee, staff and amazing location. + -Juanita Sheppard +
+ +
+ Great coffee and amazing view. + -Graham Magnuson +
+ +
+ This is a unique, albeit a bit quirky place. Creative coffee drinks. + -Artak Kalantarian +
+ +
+ This place is my daily grind awesome staff awesome owner most incredible of you in a coffee shop I've ever had + -mugen andeson +
+ +
+ Great coffee and service. + -Jeannette Heffley +
+ +
+ Great atmosphere! Nice people! AWESOME coffee and food! + -N R +
+ +
+ Best coffee/espresso on the peninsula. + -Rachel Rutledge +
+ +
+ Awesome view and atmosphere. Good coffee as well. + -Andrew Law +
+ +
+ Best coffee by a mile, good service. Ambiance : Gemuetlich. + -Hans Knigge +
+ +
+ I was just there with my girlfriend on a rainy day. We got the best hot chocolate we have ever had. + -Will Hale +
+ +
+ Grab you Cappuccino, go outside and enjoy the waves on the beach. Life is good. + -Bill Crane +
+ +
+ Beautiful setting great coffee fabulous service I will be back + -Karen Karadimov +
+ +
+ Amazing coffee and food, friendly service and chill atmosphere + -Muldair Moore +
+ +
+ Nice staff and friendly atmosphere and good coffee too. + -Chainarong Patana-anake +
+ +
+ Amazing views, great coffee, and a large local following + -Bradford Billings +
+ +
+ Great coffee. Good service. Relaxing environment. + -Bob Phillips +
+ +
+ Great coffee, food and views + -Chris Langston +
+ +
+ Best coffee ever! Waterfront, great location. + -Susan Willis +
+ +
+ Such care, great food, killin' views & the coffee zings. Good. + -Scott Doran +
+ +
+ Great service!! Great staff!! Very good coffee!! + -casey reuck +
+ +
+ The coffee goes well with the view. Makes sense why it's so popular. + -Dabin Park +
+ +
+ Great coffee. Excellent food. Beautiful views + -James Reynolds +
+ +
+ Excellent espresso super service + -Sharon Jack +
+ +
+ Good coffee and good prices. Starbucks ain't got nothing on BLTC. + -Garrett Massey +
+ +
+ Oh the coffee is simply the best. + -Stephen Smith +
+ +
+ Great coffee! + -Jessica Dugal +
+ +
+ They make the best salted Caramel lattes in the world. + -Margaret Barbo +
+ +
+ What not to love...the view, the organic way of Caffeine, the friendly fun atmosphere… + -Dr. Joseph Adam +
+ +
+ Great coffee. Loved it + -Rapunzel +
+ +
+ Such good coffee + -Peggy Baker +
+ +
+ Great local coffee shop, awesome coffee + -Parley Wells +
+ +
+ Best Sumatra dark drip to go. Great people. + -Scott Tennant +
+ +
+ Great food, great coffee, hardworking staff + -Stevie Schmidt +
+ +
+ Best coffee in Port Townsend! + -Robert Maass +
+ +
+ Friendly place. Awesome people and great coffee! + -Brett Merkley +
+ +
+ The Coffee Here Is Really Great. It's great. The coffee + -John Buday +
+ +
+ Superb quad-shot espresso drinks and pastries straight from the angels! + -Bradley Smull +
+ +
+ A comfortable and relaxing place with delicious coffee. + -Morgan Humphreys +
+ +
+ Better Living With Coffee has the best salted caramel latte + -Gilbert Barbo +
+ +
+ Awesome coffee and amazing ambiance ♥️ + -Hamsa Abdullah +
+ +
+ Get the coffee Arancia. It's the best thing to happen to the planet since the polio vaccine. + -Shane Squire +
+ +
+ Delicious drip coffee for a reasonable price! + -Ian McLaughlan +
+ +
+ I love BLTC. Great coffee and great people. + -Sea Otter +
+ +
+ Beautiful view, awesome coffee, ethical standards + -Charles Boyles +
+ +
+ Great coffee, food, people and view! + -Gabe Herbert +
+ +
+ Great coffee and great food + -Scott Clarkson +
+ +
+ Great coffee, and atmosphere! + -Kristina Koranek +
+ +
+ Had a fantastic wet cappuccino + -Scott Urstad +
+ +
+ Delicious coffee in a friendly atmosphere. + -Barbara Beers +
+ +
+ The best, simply the best coffee in town. + -C Miller +
+ +
+ Best coffee in PT + -Peggy Baker +
+ +
+ Best latte I have ever had. + -Juan Haley +
+ +
+ Always my favorite coffee shop! + -leslie shawn +
+ +
+ Great location, great views, great coffee + -yr deervvitch +
+ +
+ Great coffee! + -Reed McGinnis +
+ +
+ Nice place for coffee ☕🍪🤯 + -Blowing Bubbles +
+ +
+ Amazing quality of product and personnel. + -Tim Hartzell +
+ +
+ Amazing coffee great staff + -Thomas Carso +
+ +
+ Best coffee on the west coast. + -Mark Burchfield +
+ +
+ Awesome coffee! + -Karen Fry +
+ +
+ Nice coffee + -リーBryan +
+ +
+ Loved it. Good coffee. + -Hans Schweizer +
+ +
+ My favorite reason to visit Port Townsend! + -Ilana Moss +
+ +
+ Best Espresso on the Peninsula!! + -Audra Downs +
+ +
+ The best coffee in Port Townsend + -Denice Nolan +
+ +
+ Great coffee! + -Joe Bolton +
+ +
+ Fabulous coffee and staff!! + -Candice Cotterill +
+ +
+ Good ..No GREAT COFFEE + -David Ustick +
+ +
+ I love their coffee + -Julie Canterbury +
+ +
+ Slice of heaven on the waterfront. + -John Franich +
+ +
+ A top notch coffee house + -The dog cat reptile And plant lady +
+ +
+ Very cool coffee shop. + -ramsay tanham +
+ +
+ I love love love the Pantomime French! + -Cynthia Koan +
+ +
+ Great coffee + -Eric Black +
+ +
+ Drip coffee done right + -Austin Ivey +
+ +
+ Varied, sustainable, and delicious! + -Daniel Archer +
+ +
+ 5 stars says it all! + -N Croston +
+ +
+ Yummy! + -Pearl Newt +
+ +
+ The coffee is delicious. + -Destini Jewell +
+ +
+ Delightful coffee and place. + -Ash +
+ +
+ Yummy coffee + -Nichole Van Duren +
+ +
+ Best coffee in town + -Natalie Ainge (Kerrigan Byrne) +
+ +
+ This place has it all! + -Lillian Henegar +
+ +
+ Lovely + -C.S. Hirst ndt +
+ +
+ I just love this place. ❤️ + -Kristina +
+ +
+ Delicious coffee! + -sopalla d +
+ +
+ Excellent coffee + -Tom Hunter +
+ +
+ I love everything about this place. + -James Jacoby +
+ +
+ Amazing + -Nicole Malloy +
+ +
+ Always the best choice + -ANTHONY DIORIO +
+ +
+ Excellent cup of coffee + -Monica Buchholz +
+ +
+ Wow! + -Ken P +
+ +
+ Delicious. Adina Than. Great coffee + -Angela Fusaro +
+ +
+ Yum + -Jeff Royster +
+ +
+ Good coffee + -Richard Paul +
+ +
+ Chetzemoka for the win + -Ian Sanderson +
+ +
+ Good Coffee + -Rob Kline +
+ +
+ the highlight of PT + -Jessica von Volkli +
+ +
+ Smart coffee shop and good coffee! They have good coffee and that’s not easy to find outside of Seattle really. Drip coffee made inventively. Many types to keep people coming back. + -Iwamisea +
+ +
+ Ah, Coffee! And more… Some of the best coffee in Port Townsend spoken from the lips of a long-time coffee/espresso drinker. It's fresh and strong + -James B +
+ +
+ Best coffee! This is a place for every coffee lover and coffee aficionado. + -FABJourney +
+ +
+ As somewhat of a coffee snob, finding a good cup of joe, or a great latte, is what can turn my entire day around. The latte was OUTSTANDING. + -BEST COFFEE IN PORT TOWNSEND +
+ +
+ The place to go. My only complaint is that it is too popular… + -Ryan C +
+ + +
+ Pure Port Townsend. Perfect and delicious locally roasted coffees + -janetzim +
+ +
+ Great Coffee. Love this coffee shop, very interesting spot and has a great view. The coffee is amazing! Had the Arancia (think this is how it is spelled) quite a few times and continue to crave it to this day. + -Cas M +
+ +
+ Great coffee and kind staff. We went to this place 4 days in a row recently while on vacation. Staff greeted us kindly and made us some of the best coffee we'd had in a long time. + -922aaronm +
+ +
+ Wonderful! GREAT coffee! If you are a coffee lover you must go here. + -Maggie L +
+
+
+{% endblock %} diff --git a/src/storefront/urls.py b/src/storefront/urls.py index 365cacc..e31f60e 100644 --- a/src/storefront/urls.py +++ b/src/storefront/urls.py @@ -3,6 +3,8 @@ from . import views urlpatterns = [ path('about/', views.AboutView.as_view(), name='about'), + path('reviews/', views.ReviewListView.as_view(), name='reviews'), + path('contact/', views.ContactFormView.as_view(), name='contact'), path('', views.ProductListView.as_view(), name='product-list'), path('products//', include([ @@ -11,9 +13,13 @@ urlpatterns = [ path('cart/', views.CartView.as_view(), name='cart-detail'), path('cart//add/', views.CartAddProductView.as_view(), name='cart-add'), + path('cart//update/', views.CartUpdateProductView.as_view(), name='cart-update'), path('cart//remove/', views.cart_remove_product_view, name='cart-remove'), + path('coupon/apply/', views.CouponApplyView.as_view(), name='coupon-apply'), + path('paypal/order//capture/', 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(), name='checkout-address'), path('checkout/', views.OrderCreateView.as_view(), name='order-create'), @@ -24,5 +30,9 @@ urlpatterns = [ 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('orders//', views.OrderDetailView.as_view(), name='order-detail'), + path('addresses//update/', views.AddressUpdateView.as_view(), name='address-update'), ])), + ] diff --git a/src/storefront/views.py b/src/storefront/views.py index de47e30..561ea3c 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -1,9 +1,12 @@ import logging import requests +import json 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.exceptions import ObjectDoesNotExist from django.http import JsonResponse from django.views.generic.base import RedirectView, TemplateView from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView, FormMixin @@ -11,16 +14,20 @@ from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.list import ListView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment from accounts.models import User, Address from accounts.utils import get_or_create_customer -from core.models import Product, Order, Transaction, OrderLine +from accounts.forms import AddressForm as AccountAddressForm, CustomerUpdateForm +from core.models import Product, Order, Transaction, OrderLine, Coupon from core.forms import ShippingMethodForm -from .forms import AddToCartForm, OrderCreateForm, AddressForm +from .forms import AddToCartForm, UpdateCartItemForm, OrderCreateForm, AddressForm, CouponApplyForm, ContactForm from .cart import Cart from .payments import CaptureOrder @@ -33,14 +40,13 @@ class CartView(TemplateView): context = super().get_context_data(**kwargs) cart = Cart(self.request) for item in cart: - item['update_quantity_form'] = AddToCartForm( + item['update_quantity_form'] = UpdateCartItemForm( initial={ 'quantity': item['quantity'], - 'roast': item['roast'], - 'update': True } ) context['cart'] = cart + context['coupon_apply_form'] = CouponApplyForm() return context class CartAddProductView(SingleObjectMixin, FormView): @@ -56,7 +62,30 @@ class CartAddProductView(SingleObjectMixin, FormView): if form.is_valid(): cart.add( product=self.get_object(), - roast=form.cleaned_data['roast'], + grind=form.cleaned_data['grind'], + quantity=form.cleaned_data['quantity'] + ) + 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 + form_class = UpdateCartItemForm + + def get_success_url(self): + return reverse('storefront:cart-detail') + + def post(self, request, *args, **kwargs): + cart = Cart(request) + form = self.get_form() + if form.is_valid(): + cart.add( + product=self.get_object(), quantity=form.cleaned_data['quantity'], update_quantity=form.cleaned_data['update'] ) @@ -75,10 +104,32 @@ def cart_remove_product_view(request, pk): return redirect('storefront:cart-detail') +class CouponApplyView(FormView): + template_name = 'contact.html' + form_class = CouponApplyForm + success_url = reverse_lazy('storefront:cart-detail') + + def form_valid(self, form): + today = timezone.localtime(timezone.now()).date() + code = form.cleaned_data['code'] + try: + coupon = Coupon.objects.get( + code__iexact=code, + valid_from__date__lte=today, + valid_to__date__gte=today + ) + if coupon.is_valid: + self.request.session['coupon_code'] = coupon.code + except ObjectDoesNotExist: + self.request.session['coupon_code'] = None + return super().form_valid(form) + + class ProductListView(FormMixin, ListView): model = Product template_name = 'storefront/product_list.html' form_class = AddToCartForm + ordering = 'sorting' queryset = Product.objects.filter( visible_in_listings=True @@ -127,6 +178,7 @@ class OrderCreateView(CreateView): def get_initial(self): cart = Cart(self.request) initial = { + 'coupon': cart.coupon, 'total_net_amount': cart.get_total_price() } @@ -151,9 +203,11 @@ class OrderCreateView(CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['shipping_address'] = self.request.session.get('shipping_address') + context['PAYPAL_CLIENT_ID'] = settings.PAYPAL_CLIENT_ID return context def form_valid(self, form): + # TODO: make order status "Draft" and then in the PP capture set status appropriately cart = Cart(self.request) shipping_address = self.request.session.get('shipping_address') form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address) @@ -182,6 +236,13 @@ def paypal_order_transaction_capture(request, transaction_id): 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' @@ -190,24 +251,57 @@ class PaymentCanceledView(TemplateView): template_name = 'storefront/payment_canceled.html' -class CustomerDetailView(DetailView): +class CustomerDetailView(LoginRequiredMixin, DetailView): model = User template_name = 'storefront/customer_detail.html' context_object_name = 'customer' -class CustomerUpdateView(UpdateView): +class CustomerUpdateView(LoginRequiredMixin, UpdateView): model = User template_name = 'storefront/customer_form.html' context_object_name = 'customer' - fields = ( - 'first_name', - 'last_name', - 'email', - 'default_shipping_address' - ) + form_class = CustomerUpdateForm def get_success_url(self): return reverse('storefront:customer-detail', kwargs={'pk': self.object.pk}) +class OrderDetailView(LoginRequiredMixin, DetailView): + model = Order + template_name = 'storefront/order_detail.html' + pk_url_kwarg = 'order_pk' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['customer'] = User.objects.get(pk=self.kwargs['pk']) + return context + +class AddressUpdateView(LoginRequiredMixin, UpdateView): + model = Address + pk_url_kwarg = 'address_pk' + template_name = 'storefront/address_form.html' + form_class = AccountAddressForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['customer'] = User.objects.get(pk=self.kwargs['pk']) + return context + + def get_success_url(self): + return reverse('storefront:customer-detail',kwargs={'pk': self.kwargs['pk']}) + + + class AboutView(TemplateView): template_name = 'storefront/about.html' + +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') + + def form_valid(self, form): + form.send_email() + return super().form_valid(form) diff --git a/src/templates/account/email.html b/src/templates/account/email.html index d54b282..9884d05 100644 --- a/src/templates/account/email.html +++ b/src/templates/account/email.html @@ -6,6 +6,7 @@ {% block content %}
+

← Back

{% trans "E-mail Addresses" %}

@@ -16,10 +17,10 @@ {% csrf_token %}
{% for emailaddress in user.emailaddress_set.all %} -
+
+
+ + diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index 5cbc9af..f85ae1d 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -41,13 +41,13 @@ Customers - + Coupons - + - Staff + Config
@@ -67,8 +67,24 @@ {% endblock content %}
+{% if messages %} +
+{% for message in messages %} + {{ message }} +{% endfor %} +
+{% endif %}
+ + diff --git a/src/templates/templated_email/storefront/contact_form.email b/src/templates/templated_email/storefront/contact_form.email new file mode 100644 index 0000000..eed4a74 --- /dev/null +++ b/src/templates/templated_email/storefront/contact_form.email @@ -0,0 +1,16 @@ +{% block subject %}{{subject}}{% endblock %} +{% block plain %} + Referred from: {{referal}} + + From: {{first_name}} {{last_name}} {{email_address}} + + Message: {{message}} +{% endblock %} + +{% block html %} +

Referred from:
{{referal}}

+ +

From:
{{first_name}} {{last_name}} {{email_address|urlize}}

+ +

Message:
{{message|linebreaks}}

+{% endblock %} diff --git a/src/templates/templated_email/storefront/order_shipped.email b/src/templates/templated_email/storefront/order_shipped.email new file mode 100644 index 0000000..9537410 --- /dev/null +++ b/src/templates/templated_email/storefront/order_shipped.email @@ -0,0 +1,18 @@ +{% block subject %}Your PT Coffee order #{{order_id}} has shipped{% endblock %} +{% block plain %} + Great news! Your recent order #{{order_id}} has shipped + + {{tracking_id}} + + Thanks, + Port Townsend Coffee +{% endblock %} + +{% block html %} +

Great news! Your recent order #{{order_id}} has shipped

+ +

{{tracking_id}}

+ +

Thanks,
+ Port Townsend Coffee

+{% endblock %}