commit 7df5e9272074d5590bc0e869f37fa652511807d3 Author: Nathan Chapman Date: Thu Mar 10 20:24:46 2022 -0700 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a80e77e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,37 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 +continuation_indent_size = 8 +combine_as_imports = true +max_line_length = 88 +multi_line_output = 4 +quote_type = single + +[*.js] +indent_size = 4 + +[*.jsx] +indent_size = 4 + +[*.scss] +indent_size = 4 + +[*.ts] +indent_size = 4 + +[*.tsx] +indent_size = 4 + +[*.yml] +indent_size = 4 + +[*.html] +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3215f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +.idea +./.idea +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.pypirc +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +.pg_service.conf +.pgpass + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..6f56539 --- /dev/null +++ b/Pipfile @@ -0,0 +1,29 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +celery = {extras = ["redis"], version = "*"} +Django = "==4.0.2" +django-allauth = "*" +django-anymail = {extras = ["mailgun"], version = "*"} +django-celery-beat = "*" +django-celery-results = "*" +django-compressor = "*" +django-filter = "*" +django-measurement = "*" +django-setup-cli = "*" +django-storages = "*" +django-templated-email = "*" +paypal-checkout-serversdk = "*" +Pillow = "*" +redis = "*" +psycopg2 = "*" + +[dev-packages] +django-debug-toolbar = "*" +selenium = "*" + +[requires] +python_version = "3.10" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..7956781 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,982 @@ +{ + "_meta": { + "hash": { + "sha256": "f7ca5d3f9e367e3324d6fd9af8c1022cff36b1a999eb7afa232ac1af8404c62c" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.10" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "amqp": { + "hashes": [ + "sha256:446b3e8a8ebc2ceafd424ffcaab1c353830d48161256578ed7a65448e601ebed", + "sha256:a575f4fa659a2290dc369b000cff5fea5c6be05fe3f2d5e511bcf56c7881c3ef" + ], + "markers": "python_version >= '3.6'", + "version": "==5.1.0" + }, + "asgiref": { + "hashes": [ + "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", + "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" + ], + "markers": "python_version >= '3.7'", + "version": "==3.5.0" + }, + "billiard": { + "hashes": [ + "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547", + "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b" + ], + "version": "==3.6.4.0" + }, + "celery": { + "extras": [ + "redis" + ], + "hashes": [ + "sha256:8aacd02fc23a02760686d63dde1eb0daa9f594e735e73ea8fb15c2ff15cb608c", + "sha256:e2cd41667ad97d4f6a2f4672d1c6a6ebada194c619253058b5f23704aaadaa82" + ], + "index": "pypi", + "version": "==5.2.3" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "cffi": { + "hashes": [ + "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", + "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", + "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", + "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", + "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", + "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", + "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", + "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", + "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", + "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", + "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", + "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", + "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", + "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", + "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", + "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", + "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", + "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", + "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", + "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", + "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", + "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", + "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", + "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", + "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", + "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", + "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", + "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", + "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", + "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", + "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", + "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", + "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", + "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", + "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", + "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", + "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", + "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", + "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", + "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", + "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", + "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", + "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", + "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", + "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", + "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", + "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", + "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", + "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", + "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" + ], + "version": "==1.15.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "click": { + "hashes": [ + "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1", + "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb" + ], + "markers": "python_version >= '3.6'", + "version": "==8.0.4" + }, + "click-didyoumean": { + "hashes": [ + "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", + "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035" + ], + "markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", + "version": "==0.3.0" + }, + "click-plugins": { + "hashes": [ + "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", + "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8" + ], + "version": "==1.1.1" + }, + "click-repl": { + "hashes": [ + "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b", + "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8" + ], + "version": "==0.2.0" + }, + "cryptography": { + "hashes": [ + "sha256:0a817b961b46894c5ca8a66b599c745b9a3d9f822725221f0e0fe49dc043a3a3", + "sha256:2d87cdcb378d3cfed944dac30596da1968f88fb96d7fc34fdae30a99054b2e31", + "sha256:30ee1eb3ebe1644d1c3f183d115a8c04e4e603ed6ce8e394ed39eea4a98469ac", + "sha256:391432971a66cfaf94b21c24ab465a4cc3e8bf4a939c1ca5c3e3a6e0abebdbcf", + "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316", + "sha256:4caa4b893d8fad33cf1964d3e51842cd78ba87401ab1d2e44556826df849a8ca", + "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638", + "sha256:596f3cd67e1b950bc372c33f1a28a0692080625592ea6392987dba7f09f17a94", + "sha256:5d59a9d55027a8b88fd9fd2826c4392bd487d74bf628bb9d39beecc62a644c12", + "sha256:6c0c021f35b421ebf5976abf2daacc47e235f8b6082d3396a2fe3ccd537ab173", + "sha256:73bc2d3f2444bcfeac67dd130ff2ea598ea5f20b40e36d19821b4df8c9c5037b", + "sha256:74d6c7e80609c0f4c2434b97b80c7f8fdfaa072ca4baab7e239a15d6d70ed73a", + "sha256:7be0eec337359c155df191d6ae00a5e8bbb63933883f4f5dffc439dac5348c3f", + "sha256:94ae132f0e40fe48f310bba63f477f14a43116f05ddb69d6fa31e93f05848ae2", + "sha256:bb5829d027ff82aa872d76158919045a7c1e91fbf241aec32cb07956e9ebd3c9", + "sha256:ca238ceb7ba0bdf6ce88c1b74a87bffcee5afbfa1e41e173b1ceb095b39add46", + "sha256:ca28641954f767f9822c24e927ad894d45d5a1e501767599647259cbf030b903", + "sha256:e0344c14c9cb89e76eb6a060e67980c9e35b3f36691e15e1b7a9e58a0a6c6dc3", + "sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1", + "sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee" + ], + "version": "==36.0.1" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" + }, + "deprecated": { + "hashes": [ + "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d", + "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.2.13" + }, + "django": { + "hashes": [ + "sha256:110fb58fb12eca59e072ad59fc42d771cd642dd7a2f2416582aa9da7a8ef954a", + "sha256:996495c58bff749232426c88726d8cd38d24c94d7c1d80835aafffa9bc52985a" + ], + "index": "pypi", + "version": "==4.0.2" + }, + "django-allauth": { + "hashes": [ + "sha256:f5fbb67376177c6a9276516dde98bcb01ac4160a5a27f7b340914dd521d04f12" + ], + "index": "pypi", + "version": "==0.49.0" + }, + "django-anymail": { + "extras": [ + "mailgun" + ], + "hashes": [ + "sha256:2325932f56f914d96e0a54db850f2b246ed2277b753f75319620d051a51551e2", + "sha256:677e937dc9e2671ca7631abb1d94ddc6b840beb3d53c0fbf699e866a6a9ba92f" + ], + "index": "pypi", + "version": "==8.5" + }, + "django-appconf": { + "hashes": [ + "sha256:ae9f864ee1958c815a965ed63b3fba4874eec13de10236ba063a788f9a17389d", + "sha256:be3db0be6c81fa84742000b89a81c016d70ae66a7ccb620cdef592b1f1a6aaa4" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.5" + }, + "django-celery-beat": { + "hashes": [ + "sha256:4eb0e8412e2e05ba0029912a6f80d1054731001eecbcb4d59688c4e07cf4d9d3", + "sha256:8a169e11d96faed8b72d505ddbc70e7fe0b16cdc854df43cb209c153ed08d651" + ], + "index": "pypi", + "version": "==2.1.0" + }, + "django-celery-results": { + "hashes": [ + "sha256:203cf7321081d09be91738aff715c97bcc769db8c727621049e2786118059dac", + "sha256:37b8734ad0038cdeabe9efb09721dfdbe1ff7bf6e1d81ff3e10a1eb23a2b321f" + ], + "index": "pypi", + "version": "==2.3.0" + }, + "django-compressor": { + "hashes": [ + "sha256:89f7ba86777b30672c2f9c7557bf2aff87c5890903c73b1fa3ae38acd143e855", + "sha256:c4a87bf65f9a534cfaf1c321a000a229c24e50c6d62ba6ab089482db42e819d9" + ], + "index": "pypi", + "version": "==3.1" + }, + "django-filter": { + "hashes": [ + "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e", + "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063" + ], + "index": "pypi", + "version": "==21.1" + }, + "django-measurement": { + "hashes": [ + "sha256:b2d40b8b56b4e8277130a2a8cbc1f01f597589a636e0ea7dfbc4e4c05d458cef", + "sha256:db1279b04bacf3b48259312adaaefcfe55ca30b1e0933fa37d6c387c156834d5" + ], + "index": "pypi", + "version": "==3.2.4" + }, + "django-render-block": { + "hashes": [ + "sha256:a01bfdb839e2f6b3f88a99021597484392bbd15d084f9a796e3e5658bae800f4", + "sha256:fbdd8be56cefcfd794756a2e62117cc031f9c5de3ef4bb53e9a3f877a359a1a7" + ], + "markers": "python_version >= '3.6'", + "version": "==0.9.1" + }, + "django-setup-cli": { + "hashes": [ + "sha256:206f1036e49d4d64012ad2d306fa4015592335a7c6d006ed708d368a13abf5c6", + "sha256:5ea5517cd58d2c77a32fe14373cf977d4b5275a6423321ca08851bcade35601c" + ], + "index": "pypi", + "version": "==1.0.17" + }, + "django-storages": { + "hashes": [ + "sha256:204a99f218b747c46edbfeeb1310d357f83f90fa6a6024d8d0a3f422570cee84", + "sha256:a475edb2f0f04c4f7e548919a751ecd50117270833956ed5bd585c0575d2a5e7" + ], + "index": "pypi", + "version": "==1.12.3" + }, + "django-templated-email": { + "hashes": [ + "sha256:49d61840ec551e640adaf341146e94d6f9058ae01df964480850bf988046e5eb", + "sha256:bf1b68ffe6c8794c0c50e2ce20e3a166c6d511b3879abbd3cf059a3fc2fe2e60" + ], + "index": "pypi", + "version": "==3.0.0" + }, + "django-timezone-field": { + "hashes": [ + "sha256:5dd5bd9249382bef8847d3e7e4c32b7be182a4b538f354130d1252ed228892f8", + "sha256:7552d2b0f145684b7de3fb5046101c7efd600cc6ba951b15c630fa1e1b83558e" + ], + "markers": "python_version >= '3.5'", + "version": "==4.2.3" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "kombu": { + "hashes": [ + "sha256:37cee3ee725f94ea8bb173eaab7c1760203ea53bbebae226328600f9d2799610", + "sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4" + ], + "markers": "python_version >= '3.7'", + "version": "==5.2.4" + }, + "measurement": { + "hashes": [ + "sha256:352b20f7f0e553236af7c5ed48d091a51cf26061c1a063f46b31706ff7c0d57a" + ], + "version": "==3.2.0" + }, + "mpmath": { + "hashes": [ + "sha256:604bc21bd22d2322a177c73bdb573994ef76e62edd595d17e00aff24b0667e5c", + "sha256:79ffb45cf9f4b101a807595bcb3e72e0396202e0b1d25d689134b48c4216a81a" + ], + "version": "==1.2.1" + }, + "oauthlib": { + "hashes": [ + "sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2", + "sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe" + ], + "markers": "python_version >= '3.6'", + "version": "==3.2.0" + }, + "packaging": { + "hashes": [ + "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", + "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" + ], + "markers": "python_version >= '3.6'", + "version": "==21.3" + }, + "paypal-checkout-serversdk": { + "hashes": [ + "sha256:80f62ba2d9fe22b58c2ce1f310146acf6037088493398dba8b1bb67b493aee5e", + "sha256:e82bf50c249d7383cb4f68d8562a68dde3e7089389f286c111b6689bd3fdad36" + ], + "index": "pypi", + "version": "==1.0.1" + }, + "paypalhttp": { + "hashes": [ + "sha256:0a712ef72f061e80362cff83b094a7a64cdbbd3e4f11b12657eb1a82acf0f8c9", + "sha256:20e00f95ea052f59145b65bc2baf3b8720f449460c96bf7d32f191c8e293d16d", + "sha256:251a6e72e2c5140c5372ee6351b64f7af61b5aad9c554618db5782a06205989a" + ], + "version": "==1.0.1" + }, + "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" + ], + "index": "pypi", + "version": "==9.0.1" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c", + "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.0.28" + }, + "psycopg2": { + "hashes": [ + "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c", + "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf", + "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362", + "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7", + "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461", + "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126", + "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981", + "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56", + "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305", + "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2", + "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca" + ], + "index": "pypi", + "version": "==2.9.3" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, + "pyjwt": { + "extras": [ + "crypto" + ], + "hashes": [ + "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41", + "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f" + ], + "markers": "python_version >= '3.6'", + "version": "==2.3.0" + }, + "pyopenssl": { + "hashes": [ + "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf", + "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0" + ], + "markers": "python_version >= '3.6'", + "version": "==22.0.0" + }, + "pyparsing": { + "hashes": [ + "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", + "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.7" + }, + "python-crontab": { + "hashes": [ + "sha256:1e35ed7a3cdc3100545b43e196d34754e6551e7f95e4caebbe0e1c0ca41c2f1b" + ], + "version": "==2.6.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, + "python-dotenv": { + "hashes": [ + "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3", + "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f" + ], + "markers": "python_version >= '3.5'", + "version": "==0.19.2" + }, + "python3-openid": { + "hashes": [ + "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", + "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" + ], + "version": "==3.2.0" + }, + "pytz": { + "hashes": [ + "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", + "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" + ], + "version": "==2021.3" + }, + "pyyaml": { + "hashes": [ + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0" + }, + "rcssmin": { + "hashes": [ + "sha256:0a6aae7e119509445bf7aa6da6ca0f285cc198273c20f470ad999ff83bbadcf9", + "sha256:1512223b6a687bb747e4e531187bd49a56ed71287e7ead9529cbaa1ca4718a0a", + "sha256:1d7c2719d014e4e4df4e33b75ae8067c7e246cf470eaec8585e06e2efac7586c", + "sha256:2211a5c91ea14a5937b57904c9121f8bfef20987825e55368143da7d25446e3b", + "sha256:27fc400627fd3d328b7fe95af2a01f5d0af6b5af39731af5d071826a1f08e362", + "sha256:30f5522285065cae0164d20068377d84b5d10b414156115f8729b034d0ea5e8b", + "sha256:32ccaebbbd4d56eab08cf26aed36f5d33389b9d1d3ca1fecf53eb6ab77760ddf", + "sha256:352dd3a78eb914bb1cb269ac2b66b3154f2490a52ab605558c681de3fb5194d2", + "sha256:37f1242e34ca273ed2c26cf778854e18dd11b31c6bfca60e23fce146c84667c1", + "sha256:49807735f26f59404194f1e6f93254b6d5b6f7748c2a954f4470a86a40ff4c13", + "sha256:506e33ab4c47051f7deae35b6d8dbb4a5c025f016e90a830929a1ecc7daa1682", + "sha256:6158d0d86cd611c5304d738dc3d6cfeb23864dd78ad0d83a633f443696ac5d77", + "sha256:7085d1b51dd2556f3aae03947380f6e9e1da29fb1eeadfa6766b7f105c54c9ff", + "sha256:7c44002b79f3656348196005b9522ec5e04f182b466f66d72b16be0bd03c13d8", + "sha256:7da63fee37edf204bbd86785edb4d7491642adbfd1d36fd230b7ccbbd8db1a6f", + "sha256:8b659a88850e772c84cfac4520ec223de6807875e173d8ef3248ab7f90876066", + "sha256:c28b9eb20982b45ebe6adef8bd2547e5ed314dafddfff4eba806b0f8c166cfd1", + "sha256:ddff3a41611664c7f1d9e3d8a9c1669e0e155ac0458e586ffa834dc5953e7d9f", + "sha256:f1a37bbd36b050813673e62ae6464467548628690bf4d48a938170e121e8616e", + "sha256:f31c82d06ba2dbf33c20db9550157e80bb0c4cbd24575c098f0831d1d2e3c5df" + ], + "version": "==1.1.0" + }, + "redis": { + "hashes": [ + "sha256:04629f8e42be942c4f7d1812f2094568f04c612865ad19ad3ace3005da70631a", + "sha256:1d9a0cdf89fdd93f84261733e24f55a7bbd413a9b219fdaf56e3e728ca9a2306" + ], + "index": "pypi", + "version": "==4.1.4" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.27.1" + }, + "requests-oauthlib": { + "hashes": [ + "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", + "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.3.1" + }, + "rjsmin": { + "hashes": [ + "sha256:05efa485dfddb6418e3b86d8862463aa15641a61f6ae05e7e6de8f116ee77c69", + "sha256:1622fbb6c6a8daaf77da13cc83356539bfe79c1440f9664b02c7f7b150b9a18e", + "sha256:1c93b29fd725e61718299ffe57de93ff32d71b313eaabbfcc7bd32ddb82831d5", + "sha256:2ed83aca637186bafdc894b4b7fc3657e2d74014ccca7d3d69122c1e82675216", + "sha256:38a4474ed52e1575fb9da983ec8657faecd8ab3738508d36e04f87769411fd3d", + "sha256:3b14f4c2933ec194eb816b71a0854ce461b6419a3d852bf360344731ab28c0a6", + "sha256:40e7211a25d9a11ac9ff50446e41268c978555676828af86fa1866615823bfff", + "sha256:41c7c3910f7b8816e37366b293e576ddecf696c5f2197d53cf2c1526ac336646", + "sha256:4387a00777faddf853eebdece9f2e56ebaf243c3f24676a9de6a20c5d4f3d731", + "sha256:54fc30519365841b27556ccc1cb94c5b4413c384ff6d467442fddba66e2e325a", + "sha256:6c395ffc130332cca744f081ed5efd5699038dcb7a5d30c3ff4bc6adb5b30a62", + "sha256:6c529feb6c400984452494c52dd9fdf59185afeacca2afc5174a28ab37751a1b", + "sha256:86c4da7285ddafe6888cb262da563570f28e4a31146b5164a7a6947b1222196b", + "sha256:8944a8a55ac825b8e5ec29f341ecb7574697691ef416506885898d2f780fb4ca", + "sha256:993935654c1311280e69665367d7e6ff694ac9e1609168cf51cae8c0307df0db", + "sha256:99e5597a812b60058baa1457387dc79cca7d273b2a700dc98bfd20d43d60711d", + "sha256:b6a7c8c8d19e154334f640954e43e57283e87bb4a2f6e23295db14eea8e9fc1d", + "sha256:c81229ffe5b0a0d5b3b5d5e6d0431f182572de9e9a077e85dbae5757db0ab75c", + "sha256:d63e193a2f932a786ae82068aa76d1d126fcdff8582094caff9e5e66c4dcc124", + "sha256:e18fe1a610fb105273bb369f61c2b0bd9e66a3f0792e27e4cac44e42ace1968b" + ], + "version": "==1.2.0" + }, + "setuptools": { + "hashes": [ + "sha256:22c7348c6d2976a52632c67f7ab0cdf40147db7789f9aed18734643fe9cf3373", + "sha256:4ce92f1e1f8f01233ee9952c04f6b81d1e02939d6e1b488428154974a4d0783e" + ], + "markers": "python_version >= '3.6'", + "version": "==59.6.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "sqlparse": { + "hashes": [ + "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", + "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.2" + }, + "sympy": { + "hashes": [ + "sha256:2009368e862cd29f1b568dc6572786371a2faa1cd8eb4d313e11a90195d6ee36", + "sha256:6cf85a5cfe8fff69553e745b05128de6fc8de8f291965c63871c79701dc6efc9" + ], + "markers": "python_version >= '3.7'", + "version": "==1.10" + }, + "urllib3": { + "hashes": [ + "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", + "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + ], + "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.8" + }, + "vine": { + "hashes": [ + "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", + "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" + ], + "markers": "python_version >= '3.6'", + "version": "==5.0.0" + }, + "wcwidth": { + "hashes": [ + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" + ], + "version": "==0.2.5" + }, + "wrapt": { + "hashes": [ + "sha256:00108411e0f34c52ce16f81f1d308a571df7784932cc7491d1e94be2ee93374b", + "sha256:01f799def9b96a8ec1ef6b9c1bbaf2bbc859b87545efbecc4a78faea13d0e3a0", + "sha256:09d16ae7a13cff43660155383a2372b4aa09109c7127aa3f24c3cf99b891c330", + "sha256:14e7e2c5f5fca67e9a6d5f753d21f138398cad2b1159913ec9e9a67745f09ba3", + "sha256:167e4793dc987f77fd476862d32fa404d42b71f6a85d3b38cbce711dba5e6b68", + "sha256:1807054aa7b61ad8d8103b3b30c9764de2e9d0c0978e9d3fc337e4e74bf25faa", + "sha256:1f83e9c21cd5275991076b2ba1cd35418af3504667affb4745b48937e214bafe", + "sha256:21b1106bff6ece8cb203ef45b4f5778d7226c941c83aaaa1e1f0f4f32cc148cd", + "sha256:22626dca56fd7f55a0733e604f1027277eb0f4f3d95ff28f15d27ac25a45f71b", + "sha256:23f96134a3aa24cc50614920cc087e22f87439053d886e474638c68c8d15dc80", + "sha256:2498762814dd7dd2a1d0248eda2afbc3dd9c11537bc8200a4b21789b6df6cd38", + "sha256:28c659878f684365d53cf59dc9a1929ea2eecd7ac65da762be8b1ba193f7e84f", + "sha256:2eca15d6b947cfff51ed76b2d60fd172c6ecd418ddab1c5126032d27f74bc350", + "sha256:354d9fc6b1e44750e2a67b4b108841f5f5ea08853453ecbf44c81fdc2e0d50bd", + "sha256:36a76a7527df8583112b24adc01748cd51a2d14e905b337a6fefa8b96fc708fb", + "sha256:3a0a4ca02752ced5f37498827e49c414d694ad7cf451ee850e3ff160f2bee9d3", + "sha256:3a71dbd792cc7a3d772ef8cd08d3048593f13d6f40a11f3427c000cf0a5b36a0", + "sha256:3a88254881e8a8c4784ecc9cb2249ff757fd94b911d5df9a5984961b96113fff", + "sha256:47045ed35481e857918ae78b54891fac0c1d197f22c95778e66302668309336c", + "sha256:4775a574e9d84e0212f5b18886cace049a42e13e12009bb0491562a48bb2b758", + "sha256:493da1f8b1bb8a623c16552fb4a1e164c0200447eb83d3f68b44315ead3f9036", + "sha256:4b847029e2d5e11fd536c9ac3136ddc3f54bc9488a75ef7d040a3900406a91eb", + "sha256:59d7d92cee84a547d91267f0fea381c363121d70fe90b12cd88241bd9b0e1763", + "sha256:5a0898a640559dec00f3614ffb11d97a2666ee9a2a6bad1259c9facd01a1d4d9", + "sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7", + "sha256:5b835b86bd5a1bdbe257d610eecab07bf685b1af2a7563093e0e69180c1d4af1", + "sha256:5f24ca7953f2643d59a9c87d6e272d8adddd4a53bb62b9208f36db408d7aafc7", + "sha256:61e1a064906ccba038aa3c4a5a82f6199749efbbb3cef0804ae5c37f550eded0", + "sha256:65bf3eb34721bf18b5a021a1ad7aa05947a1767d1aa272b725728014475ea7d5", + "sha256:6807bcee549a8cb2f38f73f469703a1d8d5d990815c3004f21ddb68a567385ce", + "sha256:68aeefac31c1f73949662ba8affaf9950b9938b712fb9d428fa2a07e40ee57f8", + "sha256:6915682f9a9bc4cf2908e83caf5895a685da1fbd20b6d485dafb8e218a338279", + "sha256:6d9810d4f697d58fd66039ab959e6d37e63ab377008ef1d63904df25956c7db0", + "sha256:729d5e96566f44fccac6c4447ec2332636b4fe273f03da128fff8d5559782b06", + "sha256:748df39ed634851350efa87690c2237a678ed794fe9ede3f0d79f071ee042561", + "sha256:763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a", + "sha256:8323a43bd9c91f62bb7d4be74cc9ff10090e7ef820e27bfe8815c57e68261311", + "sha256:8529b07b49b2d89d6917cfa157d3ea1dfb4d319d51e23030664a827fe5fd2131", + "sha256:87fa943e8bbe40c8c1ba4086971a6fefbf75e9991217c55ed1bcb2f1985bd3d4", + "sha256:88236b90dda77f0394f878324cfbae05ae6fde8a84d548cfe73a75278d760291", + "sha256:891c353e95bb11abb548ca95c8b98050f3620a7378332eb90d6acdef35b401d4", + "sha256:89ba3d548ee1e6291a20f3c7380c92f71e358ce8b9e48161401e087e0bc740f8", + "sha256:8c6be72eac3c14baa473620e04f74186c5d8f45d80f8f2b4eda6e1d18af808e8", + "sha256:9a242871b3d8eecc56d350e5e03ea1854de47b17f040446da0e47dc3e0b9ad4d", + "sha256:9a3ff5fb015f6feb78340143584d9f8a0b91b6293d6b5cf4295b3e95d179b88c", + "sha256:9a5a544861b21e0e7575b6023adebe7a8c6321127bb1d238eb40d99803a0e8bd", + "sha256:9d57677238a0c5411c76097b8b93bdebb02eb845814c90f0b01727527a179e4d", + "sha256:9d8c68c4145041b4eeae96239802cfdfd9ef927754a5be3f50505f09f309d8c6", + "sha256:9d9fcd06c952efa4b6b95f3d788a819b7f33d11bea377be6b8980c95e7d10775", + "sha256:a0057b5435a65b933cbf5d859cd4956624df37b8bf0917c71756e4b3d9958b9e", + "sha256:a65bffd24409454b889af33b6c49d0d9bcd1a219b972fba975ac935f17bdf627", + "sha256:b0ed6ad6c9640671689c2dbe6244680fe8b897c08fd1fab2228429b66c518e5e", + "sha256:b21650fa6907e523869e0396c5bd591cc326e5c1dd594dcdccac089561cacfb8", + "sha256:b3f7e671fb19734c872566e57ce7fc235fa953d7c181bb4ef138e17d607dc8a1", + "sha256:b77159d9862374da213f741af0c361720200ab7ad21b9f12556e0eb95912cd48", + "sha256:bb36fbb48b22985d13a6b496ea5fb9bb2a076fea943831643836c9f6febbcfdc", + "sha256:d066ffc5ed0be00cd0352c95800a519cf9e4b5dd34a028d301bdc7177c72daf3", + "sha256:d332eecf307fca852d02b63f35a7872de32d5ba8b4ec32da82f45df986b39ff6", + "sha256:d808a5a5411982a09fef6b49aac62986274ab050e9d3e9817ad65b2791ed1425", + "sha256:d9bdfa74d369256e4218000a629978590fd7cb6cf6893251dad13d051090436d", + "sha256:db6a0ddc1282ceb9032e41853e659c9b638789be38e5b8ad7498caac00231c23", + "sha256:debaf04f813ada978d7d16c7dfa16f3c9c2ec9adf4656efdc4defdf841fc2f0c", + "sha256:f0408e2dbad9e82b4c960274214af533f856a199c9274bd4aff55d4634dedc33", + "sha256:f2f3bc7cd9c9fcd39143f11342eb5963317bd54ecc98e3650ca22704b69d9653" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.14.0" + } + }, + "develop": { + "asgiref": { + "hashes": [ + "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", + "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" + ], + "markers": "python_version >= '3.7'", + "version": "==3.5.0" + }, + "async-generator": { + "hashes": [ + "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", + "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144" + ], + "markers": "python_version >= '3.5'", + "version": "==1.10" + }, + "attrs": { + "hashes": [ + "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", + "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.4.0" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "cffi": { + "hashes": [ + "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", + "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", + "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", + "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", + "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", + "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", + "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", + "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", + "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", + "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", + "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", + "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", + "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", + "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", + "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", + "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", + "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", + "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", + "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", + "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", + "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", + "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", + "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", + "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", + "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", + "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", + "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", + "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", + "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", + "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", + "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", + "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", + "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", + "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", + "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", + "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", + "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", + "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", + "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", + "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", + "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", + "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", + "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", + "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", + "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", + "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", + "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", + "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", + "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", + "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" + ], + "version": "==1.15.0" + }, + "cryptography": { + "hashes": [ + "sha256:0a817b961b46894c5ca8a66b599c745b9a3d9f822725221f0e0fe49dc043a3a3", + "sha256:2d87cdcb378d3cfed944dac30596da1968f88fb96d7fc34fdae30a99054b2e31", + "sha256:30ee1eb3ebe1644d1c3f183d115a8c04e4e603ed6ce8e394ed39eea4a98469ac", + "sha256:391432971a66cfaf94b21c24ab465a4cc3e8bf4a939c1ca5c3e3a6e0abebdbcf", + "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316", + "sha256:4caa4b893d8fad33cf1964d3e51842cd78ba87401ab1d2e44556826df849a8ca", + "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638", + "sha256:596f3cd67e1b950bc372c33f1a28a0692080625592ea6392987dba7f09f17a94", + "sha256:5d59a9d55027a8b88fd9fd2826c4392bd487d74bf628bb9d39beecc62a644c12", + "sha256:6c0c021f35b421ebf5976abf2daacc47e235f8b6082d3396a2fe3ccd537ab173", + "sha256:73bc2d3f2444bcfeac67dd130ff2ea598ea5f20b40e36d19821b4df8c9c5037b", + "sha256:74d6c7e80609c0f4c2434b97b80c7f8fdfaa072ca4baab7e239a15d6d70ed73a", + "sha256:7be0eec337359c155df191d6ae00a5e8bbb63933883f4f5dffc439dac5348c3f", + "sha256:94ae132f0e40fe48f310bba63f477f14a43116f05ddb69d6fa31e93f05848ae2", + "sha256:bb5829d027ff82aa872d76158919045a7c1e91fbf241aec32cb07956e9ebd3c9", + "sha256:ca238ceb7ba0bdf6ce88c1b74a87bffcee5afbfa1e41e173b1ceb095b39add46", + "sha256:ca28641954f767f9822c24e927ad894d45d5a1e501767599647259cbf030b903", + "sha256:e0344c14c9cb89e76eb6a060e67980c9e35b3f36691e15e1b7a9e58a0a6c6dc3", + "sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1", + "sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee" + ], + "version": "==36.0.1" + }, + "django": { + "hashes": [ + "sha256:110fb58fb12eca59e072ad59fc42d771cd642dd7a2f2416582aa9da7a8ef954a", + "sha256:996495c58bff749232426c88726d8cd38d24c94d7c1d80835aafffa9bc52985a" + ], + "index": "pypi", + "version": "==4.0.2" + }, + "django-debug-toolbar": { + "hashes": [ + "sha256:644bbd5c428d3283aa9115722471769cac1bec189edf3a0c855fd8ff870375a9", + "sha256:6b633b6cfee24f232d73569870f19aa86c819d750e7f3e833f2344a9eb4b4409" + ], + "index": "pypi", + "version": "==3.2.4" + }, + "h11": { + "hashes": [ + "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06", + "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442" + ], + "markers": "python_version >= '3.6'", + "version": "==0.13.0" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "outcome": { + "hashes": [ + "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958", + "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967" + ], + "markers": "python_version >= '3.6'", + "version": "==1.1.0" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, + "pyopenssl": { + "hashes": [ + "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf", + "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0" + ], + "markers": "python_version >= '3.6'", + "version": "==22.0.0" + }, + "pysocks": { + "hashes": [ + "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", + "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", + "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" + ], + "version": "==1.7.1" + }, + "selenium": { + "hashes": [ + "sha256:14d28a628c831c105d38305c881c9c7847199bfd728ec84240c5e86fa1c9bd5a" + ], + "index": "pypi", + "version": "==4.1.3" + }, + "sniffio": { + "hashes": [ + "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", + "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" + ], + "markers": "python_version >= '3.5'", + "version": "==1.2.0" + }, + "sortedcontainers": { + "hashes": [ + "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", + "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" + ], + "version": "==2.4.0" + }, + "sqlparse": { + "hashes": [ + "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", + "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.2" + }, + "trio": { + "hashes": [ + "sha256:670a52d3115d0e879e1ac838a4eb999af32f858163e3a704fe4839de2a676070", + "sha256:fb2d48e4eab0dfb786a472cd514aaadc71e3445b203bc300bad93daa75d77c1a" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==0.20.0" + }, + "trio-websocket": { + "hashes": [ + "sha256:5b558f6e83cc20a37c3b61202476c5295d1addf57bd65543364e0337e37ed2bc", + "sha256:a3d34de8fac26023eee701ed1e7bf4da9a8326b61a62934ec9e53b64970fd8fe" + ], + "markers": "python_version >= '3.5'", + "version": "==0.9.2" + }, + "urllib3": { + "hashes": [ + "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", + "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + ], + "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.8" + }, + "wsproto": { + "hashes": [ + "sha256:2218cb57952d90b9fca325c0dcfb08c3bda93e8fd8070b0a17f048e2e47a521b", + "sha256:a2e56bfd5c7cd83c1369d83b5feccd6d37798b74872866e62616e0ecf111bda8" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==1.1.0" + } + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..9b6eb7f --- /dev/null +++ b/readme.md @@ -0,0 +1,25 @@ +# ptcoffee + +## How To Start + + +### 1. Activate Virtualenv + +`windows` +```cmd + /venv/scripts/activate +``` +>Your Current Working Directory + +`Ubuntu [Debian]` +```commandline +source venv/bin/activate +``` +>you can use any name instead of **venv** + +### 2. Runserver +``` +python3 src/manage.py runserver +``` + +> Built Using [django-cli](https://github.com/khan-asfi-reza/django-setup-cli) \ No newline at end of file diff --git a/setup.yaml b/setup.yaml new file mode 100644 index 0000000..1d00c44 --- /dev/null +++ b/setup.yaml @@ -0,0 +1,23 @@ +name: ptcoffee +author: Nathan Chapman +description: E-commerce website for Port Townsend Coffee +libraries: +- celery +- whitenoise +- django-filter +- django-storages +static: true +database: + port: $DATABASE_PORT + host: $DATABASE_HOST + password: $DATABASE_PASSWORD + user: $DATABASE_USER + name: $DATABASE_NAME + engine: $DATABASE_ENGINE +cache: + location: $CACHE_LOCATION + backend: $CACHE_BACKEND +required: +- psycopg2-binary +env: + SECRET_KEY: $SECRET_KEY diff --git a/src/accounts/__init__.py b/src/accounts/__init__.py new file mode 100644 index 0000000..1665b86 --- /dev/null +++ b/src/accounts/__init__.py @@ -0,0 +1,52 @@ +STATE_CHOICES = [ + ('AL', 'Alabama'), + ('AK', 'Alaska'), + ('AZ', 'Arizona'), + ('AR', 'Arkansas'), + ('CA', 'California'), + ('CO', 'Colorado'), + ('CT', 'Connecticut'), + ('DE', 'Delaware'), + ('FL', 'Florida'), + ('GA', 'Georgia'), + ('HI', 'Hawaii'), + ('ID', 'Idaho'), + ('IL', 'Illinois'), + ('IN', 'Indiana'), + ('IA', 'Iowa'), + ('KS', 'Kansas'), + ('KY', 'Kentucky'), + ('LA', 'Louisiana'), + ('ME', 'Maine'), + ('MD', 'Maryland'), + ('MA', 'Massachusetts'), + ('MI', 'Michigan'), + ('MN', 'Minnesota'), + ('MS', 'Mississippi'), + ('MO', 'Missouri'), + ('MT', 'Montana'), + ('NE', 'Nebraska'), + ('NV', 'Nevada'), + ('NH', 'New Hampshire'), + ('NJ', 'New Jersey'), + ('NM', 'New Mexico'), + ('NY', 'New York'), + ('NC', 'North Carolina'), + ('ND', 'North Dakota'), + ('OH', 'Ohio'), + ('OK', 'Oklahoma'), + ('OR', 'Oregon'), + ('PA', 'Pennsylvania'), + ('RI', 'Rhode Island'), + ('SC', 'South Carolina'), + ('SD', 'South Dakota'), + ('TN', 'Tennessee'), + ('TX', 'Texas'), + ('UT', 'Utah'), + ('VT', 'Vermont'), + ('VA', 'Virginia'), + ('WA', 'Washington'), + ('WV', 'West Virginia'), + ('WI', 'Wisconsin'), + ('WY', 'Wyoming'), +] diff --git a/src/accounts/admin.py b/src/accounts/admin.py new file mode 100644 index 0000000..20b4b3f --- /dev/null +++ b/src/accounts/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.admin import UserAdmin + +from .models import Address, User +from .forms import AccountCreateForm, AccountUpdateForm + +class UserAdmin(UserAdmin): + add_form = AccountCreateForm + form = AccountUpdateForm + model = User + list_display = ['email', 'username',] + + +admin.site.register(Address) +admin.site.register(User, UserAdmin) diff --git a/src/accounts/apps.py b/src/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/src/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/src/accounts/forms.py b/src/accounts/forms.py new file mode 100644 index 0000000..aa7e2c5 --- /dev/null +++ b/src/accounts/forms.py @@ -0,0 +1,23 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm, UserChangeForm +from .models import Address, User + +class AddressForm(forms.ModelForm): + class Meta: + model = Address + fields = '__all__' + + +class AccountCreateForm(UserCreationForm): + class Meta: + model = User + fields = ('username', 'email') + +class AccountUpdateForm(UserChangeForm): + class Meta: + model = User + fields = [ + 'first_name', + 'last_name', + 'email', + ] diff --git a/src/accounts/migrations/0001_initial.py b/src/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..4ec0572 --- /dev/null +++ b/src/accounts/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 4.0.2 on 2022-03-11 02:25 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Address', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(blank=True, max_length=256)), + ('last_name', models.CharField(blank=True, max_length=256)), + ('street_address_1', models.CharField(blank=True, max_length=256)), + ('street_address_2', models.CharField(blank=True, max_length=256)), + ('city', models.CharField(blank=True, max_length=256)), + ('state', models.CharField(blank=True, choices=[('AL', 'Alabama'), ('AK', 'Alaska'), ('AZ', 'Arizona'), ('AR', 'Arkansas'), ('CA', 'California'), ('CO', 'Colorado'), ('CT', 'Connecticut'), ('DE', 'Delaware'), ('FL', 'Florida'), ('GA', 'Georgia'), ('HI', 'Hawaii'), ('ID', 'Idaho'), ('IL', 'Illinois'), ('IN', 'Indiana'), ('IA', 'Iowa'), ('KS', 'Kansas'), ('KY', 'Kentucky'), ('LA', 'Louisiana'), ('ME', 'Maine'), ('MD', 'Maryland'), ('MA', 'Massachusetts'), ('MI', 'Michigan'), ('MN', 'Minnesota'), ('MS', 'Mississippi'), ('MO', 'Missouri'), ('MT', 'Montana'), ('NE', 'Nebraska'), ('NV', 'Nevada'), ('NH', 'New Hampshire'), ('NJ', 'New Jersey'), ('NM', 'New Mexico'), ('NY', 'New York'), ('NC', 'North Carolina'), ('ND', 'North Dakota'), ('OH', 'Ohio'), ('OK', 'Oklahoma'), ('OR', 'Oregon'), ('PA', 'Pennsylvania'), ('RI', 'Rhode Island'), ('SC', 'South Carolina'), ('SD', 'South Dakota'), ('TN', 'Tennessee'), ('TX', 'Texas'), ('UT', 'Utah'), ('VT', 'Vermont'), ('VA', 'Virginia'), ('WA', 'Washington'), ('WV', 'West Virginia'), ('WI', 'Wisconsin'), ('WY', 'Wyoming')], max_length=2)), + ('postal_code', models.CharField(blank=True, max_length=20)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('addresses', models.ManyToManyField(blank=True, related_name='user_addresses', to='accounts.Address')), + ('default_billing_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')), + ('default_shipping_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/src/accounts/migrations/__init__.py b/src/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/models.py b/src/accounts/models.py new file mode 100644 index 0000000..2424387 --- /dev/null +++ b/src/accounts/models.py @@ -0,0 +1,32 @@ +from django.db import models +from django.urls import reverse +from django.contrib.auth.models import AbstractUser + +from . import STATE_CHOICES + +class Address(models.Model): + first_name = models.CharField(max_length=256, blank=True) + last_name = models.CharField(max_length=256, blank=True) + street_address_1 = models.CharField(max_length=256, blank=True) + street_address_2 = models.CharField(max_length=256, blank=True) + city = models.CharField(max_length=256, blank=True) + state = models.CharField( + max_length=2, + choices=STATE_CHOICES, + blank=True + ) + postal_code = models.CharField(max_length=20, blank=True) + + def __str__(self): + return f'{self.street_address_1} — {self.city}' + +class User(AbstractUser): + addresses = models.ManyToManyField( + Address, blank=True, related_name="user_addresses" + ) + default_shipping_address = models.ForeignKey( + Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL + ) + default_billing_address = models.ForeignKey( + Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL + ) diff --git a/src/accounts/tasks.py b/src/accounts/tasks.py new file mode 100644 index 0000000..3bf223e --- /dev/null +++ b/src/accounts/tasks.py @@ -0,0 +1,24 @@ +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 .models import User + +logger = get_task_logger(__name__) + + +ACCOUNT_CREATED_TEMPLATE = 'accounts/account_created' + +@shared_task(name='send_account_created_email') +def send_account_created_email(user): + send_templated_mail( + template_name=ACCOUNT_CREATED_TEMPLATE, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user['email']], + context=user + ) + + logger.info(f"Account created email sent to {user['email']}") diff --git a/src/accounts/templates/accounts/account_create.html b/src/accounts/templates/accounts/account_create.html new file mode 100755 index 0000000..2ca1e7e --- /dev/null +++ b/src/accounts/templates/accounts/account_create.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Sign up

+ +
+ {% csrf_token %} + {{ form.as_p }} + + +
+
+{% endblock %} diff --git a/src/accounts/templates/accounts/account_detail.html b/src/accounts/templates/accounts/account_detail.html new file mode 100755 index 0000000..d0eda10 --- /dev/null +++ b/src/accounts/templates/accounts/account_detail.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block content %} + +{% endblock %} diff --git a/src/accounts/templates/accounts/account_form.html b/src/accounts/templates/accounts/account_form.html new file mode 100755 index 0000000..c0a65d9 --- /dev/null +++ b/src/accounts/templates/accounts/account_form.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/src/accounts/templates/accounts/account_list.html b/src/accounts/templates/accounts/account_list.html new file mode 100755 index 0000000..a28560a --- /dev/null +++ b/src/accounts/templates/accounts/account_list.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Users

+ + + + + + + {% for user in user_list %} + + + + + {% empty %} + + {% endfor %} + +
UsernameName
{{ user.username }}{{user.first_name}} {{user.last_name}}
No users yet.
+
+{% endblock %} diff --git a/src/accounts/tests.py b/src/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/accounts/urls.py b/src/accounts/urls.py new file mode 100644 index 0000000..4f89f4f --- /dev/null +++ b/src/accounts/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from . import views + +urlpatterns = [ + path('', views.AccountListView.as_view(), name='account-list'), + path('/', include([ + path('', views.AccountDetailView.as_view(), name='account-detail'), + path('update/', views.AccountUpdateView.as_view(), name='account-update'), + path('delete/', views.AccountDeleteView.as_view(), name='account-delete'), + ])), +] diff --git a/src/accounts/utils.py b/src/accounts/utils.py new file mode 100644 index 0000000..afd9ccf --- /dev/null +++ b/src/accounts/utils.py @@ -0,0 +1,47 @@ +from allauth.account.models import EmailAddress + +from .models import Address, User +from .tasks import send_account_created_email + +def get_or_create_customer(request, form, shipping_address): + address, a_created = Address.objects.get_or_create( + first_name = shipping_address['first_name'], + last_name = shipping_address['last_name'], + street_address_1 = shipping_address['street_address_1'], + street_address_2 = shipping_address['street_address_2'], + city = shipping_address['city'], + state = shipping_address['state'], + postal_code = shipping_address['postal_code'] + ) + + if request.user.is_authenticated: + user = request.user + else: + user, u_created = User.objects.get_or_create( + email=form.cleaned_data['email'], + defaults = { + 'username': form.cleaned_data['email'], + 'is_staff': False, + 'is_active': True, + 'is_superuser': False, + 'first_name': form.cleaned_data['first_name'], + 'last_name': form.cleaned_data['last_name'], + 'default_shipping_address': address, + } + ) + if u_created: + password = User.objects.make_random_password() + user.set_password(password) + user.save() + + EmailAddress.objects.create(user=user, email=user.email, primary=True, verified=False) + + u = { + 'full_name': user.get_full_name(), + 'email': user.email, + 'password': password + } + + send_account_created_email.delay(u) + + return user, address diff --git a/src/accounts/views.py b/src/accounts/views.py new file mode 100644 index 0000000..c02cd1d --- /dev/null +++ b/src/accounts/views.py @@ -0,0 +1,38 @@ +from django.shortcuts import render, reverse, redirect +from django.urls import reverse_lazy +from django.views.generic.base import TemplateView +from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.forms import PasswordChangeForm + +from .models import User +from .forms import AccountUpdateForm + + +class AccountListView(LoginRequiredMixin, ListView): + model = User + template_name = 'accounts/account_list.html' + +class AccountDetailView(LoginRequiredMixin, DetailView): + model = User + template_name = 'accounts/account_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + return context + +class AccountUpdateView(LoginRequiredMixin, UpdateView): + model = User + form_class = AccountUpdateForm + template_name = 'accounts/account_form.html' + + def get_success_url(self): + pk = self.kwargs["pk"] + return reverse('account-detail', kwargs={'pk': pk}) + +class AccountDeleteView(LoginRequiredMixin, DeleteView): + model = User + success_url = reverse_lazy('account-list') diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..4c67f44 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,73 @@ +from django.conf import settings + +class DiscountValueType: + FIXED = "fixed" + PERCENTAGE = "percentage" + + CHOICES = [ + (FIXED, settings.DEFAULT_CURRENCY), + (PERCENTAGE, "%"), + ] + + +class VoucherType: + SHIPPING = "shipping" + ENTIRE_ORDER = "entire_order" + SPECIFIC_PRODUCT = "specific_product" + + CHOICES = [ + (ENTIRE_ORDER, "Entire order"), + (SHIPPING, "Shipping"), + (SPECIFIC_PRODUCT, "Specific products, collections and categories"), + ] + + +class OrderStatus: + DRAFT = "draft" # fully editable, not finalized order created by staff users + UNFULFILLED = "unfulfilled" # order with no items marked as fulfilled + PARTIALLY_FULFILLED = ( + "partially fulfilled" # order with some items marked as fulfilled + ) + FULFILLED = "fulfilled" # order with all items marked as fulfilled + + PARTIALLY_RETURNED = ( + "partially_returned" # order with some items marked as returned + ) + RETURNED = "returned" # order with all items marked as returned + CANCELED = "canceled" # permanently canceled order + + CHOICES = [ + (DRAFT, "Draft"), + (UNFULFILLED, "Unfulfilled"), + (PARTIALLY_FULFILLED, "Partially fulfilled"), + (PARTIALLY_RETURNED, "Partially returned"), + (RETURNED, "Returned"), + (FULFILLED, "Fulfilled"), + (CANCELED, "Canceled"), + ] + +class TransactionStatus: + CREATED = "CREATED" # The order was created with the specified context. + SAVED = "SAVED" # The order was saved and persisted. The order status continues to be in progress until a capture is made with final_capture = true for all purchase units within the order. + APPROVED = "APPROVED" # The customer approved the payment through the PayPal wallet or another form of guest or unbranded payment. For example, a card, bank account, or so on. + VOIDED = "VOIDED" # All purchase units in the order are voided. + COMPLETED = "COMPLETED" # The payment was authorized or the authorized payment was captured for the order. + PAYER_ACTION_REQUIRED = "PAYER_ACTION_REQUIRED" # The order requires an action from the payer (e.g. 3DS authentication). Redirect the payer to the "rel":"payer-action" HATEOAS link returned as part of the response prior to authorizing or capturing the order. + + CHOICES = [ + (CREATED, "Created"), + (SAVED, "Saved"), + (APPROVED, "Approved"), + (VOIDED, "Voided"), + (COMPLETED, "Completed"), + (PAYER_ACTION_REQUIRED, "Payer action required") + ] + +class ShippingMethodType: + PRICE_BASED = "price" + WEIGHT_BASED = "weight" + + CHOICES = [ + (PRICE_BASED, "Price based shipping"), + (WEIGHT_BASED, "Weight based shipping"), + ] diff --git a/src/core/admin.py b/src/core/admin.py new file mode 100644 index 0000000..62c9ccc --- /dev/null +++ b/src/core/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import ( + Product, + ProductPhoto, + Coupon, + ShippingMethod, + Order, + Transaction, + OrderLine, +) + +admin.site.register(Product) +admin.site.register(ProductPhoto) +admin.site.register(Coupon) +admin.site.register(ShippingMethod) +admin.site.register(Order) +admin.site.register(Transaction) +admin.site.register(OrderLine) diff --git a/src/core/apps.py b/src/core/apps.py new file mode 100644 index 0000000..456c142 --- /dev/null +++ b/src/core/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' + + def ready(self): + from .signals import order_created, transaction_created diff --git a/src/core/fixtures/core.json b/src/core/fixtures/core.json new file mode 100644 index 0000000..afcdadc --- /dev/null +++ b/src/core/fixtures/core.json @@ -0,0 +1,161 @@ +[{ + "model": "core.product", + "pk": 1, + "fields": { + "name": "Ethiopia", + "description": "Spicy espresso reminiscent of Northern Italy, on the mild side. Perfect for espresso, and steamed milk drinks. Also, a full-bodied, earthy sweet drip or Americano. Contains organic beans from Indonesia, Africa and America.", + "sku": "23468", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-19T20:15:36.292Z", + "updated_at": "2022-02-23T17:57:37.916Z" + } +}, { + "model": "core.product", + "pk": 2, + "fields": { + "name": "Sumatra", + "description": "Dark heavy-bodied roast with a lingering chocolatey taste. Organic Single origin.", + "sku": "89765", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-19T20:15:59.741Z", + "updated_at": "2022-02-23T17:58:24.210Z" + } +}, { + "model": "core.product", + "pk": 3, + "fields": { + "name": "Pantomime", + "description": "Very Dark French Roast\r\nOur darkest drip. A blend of five different beans roasted two ways. Organic Africa, Indonesia, and South and Central America.", + "sku": "565656", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-23T17:59:00.711Z", + "updated_at": "2022-02-23T17:59:00.711Z" + } +}, { + "model": "core.product", + "pk": 4, + "fields": { + "name": "Decaf", + "description": "French Roast (Water Processed)\r\n\r\n“I can’t believe it’s decaf!”. The best-tasting Swiss water process decaf we have developed over the past 30 years, for an unbelievable espresso or drip coffee. Organic Africa, Indonesia and South and Central America.", + "sku": "566565", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-23T17:59:32.099Z", + "updated_at": "2022-02-23T17:59:32.099Z" + } +}, { + "model": "core.product", + "pk": 5, + "fields": { + "name": "Moka Java Blend", + "description": "Dark Roast\r\n\r\nA classic Moka Java style blend dark roasted with organic beans for a perfect body and sweetness with a hint of citrus.", + "sku": "56466", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-23T18:05:41.742Z", + "updated_at": "2022-02-23T18:05:41.742Z" + } +}, { + "model": "core.product", + "pk": 6, + "fields": { + "name": "Loop d’ Loop", + "description": "Mild Dark Roast\r\n\r\nOur most popular blend reminiscent of Central Italy. A dark, chocolaty flavor perfect for espresso or drip. It’s dark Vienna roast properties make it ideal for steamed milk drinks. Organic Indonesia, Africa and America.", + "sku": "53264", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-23T18:06:09.881Z", + "updated_at": "2022-02-23T18:06:09.881Z" + } +}, { + "model": "core.product", + "pk": 7, + "fields": { + "name": "Dante’s Tornado", + "description": "Medium Roast\r\n\r\nFull City spicy espresso roast reminiscent of Northern Italy, on the mild side. A full- bodied, earthy sweet drip or Americano. Organic Indonesia, Africa and America.", + "sku": "78945", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-23T18:06:35.593Z", + "updated_at": "2022-02-23T18:06:35.593Z" + } +}, { + "model": "core.product", + "pk": 8, + "fields": { + "name": "Nicaragua", + "description": "Mild Roast\r\n\r\nOur mildest roast with sweet and fruity notes, containing organic beans from Nicaragua. Single origin.", + "sku": "12365", + "price": "13.40", + "weight": "0.453592:kg", + "visible_in_listings": true, + "created_at": "2022-02-23T18:06:57.624Z", + "updated_at": "2022-02-23T18:06:57.624Z" + } +}, { + "model": "core.productphoto", + "pk": 1, + "fields": { + "product": 1, + "image": "products/images/slice2.png" + } +}, { + "model": "core.productphoto", + "pk": 2, + "fields": { + "product": 2, + "image": "products/images/slice1.png" + } +}, { + "model": "core.productphoto", + "pk": 3, + "fields": { + "product": 3, + "image": "products/images/pantomime.png" + } +}, { + "model": "core.productphoto", + "pk": 4, + "fields": { + "product": 4, + "image": "products/images/decaf.png" + } +}, { + "model": "core.productphoto", + "pk": 5, + "fields": { + "product": 5, + "image": "products/images/moka_java.png" + } +}, { + "model": "core.productphoto", + "pk": 6, + "fields": { + "product": 6, + "image": "products/images/loop_d_loop.png" + } +}, { + "model": "core.productphoto", + "pk": 7, + "fields": { + "product": 7, + "image": "products/images/dantes_tornado.png" + } +}, { + "model": "core.productphoto", + "pk": 8, + "fields": { + "product": 8, + "image": "products/images/nicaragua.png" + } +}] diff --git a/src/core/forms.py b/src/core/forms.py new file mode 100644 index 0000000..50990a6 --- /dev/null +++ b/src/core/forms.py @@ -0,0 +1,12 @@ +import logging +from django import forms +from django.core.mail import EmailMessage + +from core.models import Order, OrderLine, ShippingMethod + +logger = logging.getLogger(__name__) + +class ShippingMethodForm(forms.ModelForm): + class Meta: + model = ShippingMethod + fields = '__all__' diff --git a/src/core/migrations/0001_initial.py b/src/core/migrations/0001_initial.py new file mode 100644 index 0000000..f56f516 --- /dev/null +++ b/src/core/migrations/0001_initial.py @@ -0,0 +1,114 @@ +# Generated by Django 4.0.2 on 2022-03-11 02:25 + +import core.weight +from decimal import Decimal +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_measurement.models +import measurement.measures.mass + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('draft', 'Draft'), ('unfulfilled', 'Unfulfilled'), ('partially fulfilled', 'Partially fulfilled'), ('partially_returned', 'Partially returned'), ('returned', 'Returned'), ('fulfilled', 'Fulfilled'), ('canceled', 'Canceled')], default='unfulfilled', max_length=32)), + ('total_net_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('weight', django_measurement.models.MeasurementField(default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('billing_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')), + ('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to=settings.AUTH_USER_MODEL)), + ('shipping_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')), + ], + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250)), + ('description', models.TextField(blank=True)), + ('sku', models.CharField(max_length=255, unique=True)), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)), + ('visible_in_listings', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ShippingMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(choices=[('price', 'Price based shipping'), ('weight', 'Weight based shipping')], max_length=30)), + ], + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(blank=True, choices=[('CREATED', 'Created'), ('SAVED', 'Saved'), ('APPROVED', 'Approved'), ('VOIDED', 'Voided'), ('COMPLETED', 'Completed'), ('PAYER_ACTION_REQUIRED', 'Payer action required')], default='CREATED', max_length=32)), + ('paypal_id', models.CharField(blank=True, max_length=64)), + ('confirmation_email_sent', models.BooleanField(default=False)), + ('order', models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.order')), + ], + ), + migrations.CreateModel( + name='ProductPhoto', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='products/images')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')), + ], + ), + migrations.CreateModel( + name='OrderLine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('quantity_fulfilled', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), + ('customer_note', models.TextField(blank=True, default='')), + ('currency', models.CharField(default='USD', max_length=3)), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=12)), + ('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.0'), max_digits=5)), + ('order', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='core.order')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.product')), + ], + ), + migrations.AddField( + model_name='order', + name='shipping_method', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.shippingmethod'), + ), + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('entire_order', 'Entire order'), ('shipping', 'Shipping'), ('specific_product', 'Specific products, collections and categories')], default='entire_order', max_length=20)), + ('name', models.CharField(blank=True, max_length=255, null=True)), + ('code', models.CharField(db_index=True, max_length=12, unique=True)), + ('valid_from', models.DateTimeField(default=django.utils.timezone.now)), + ('valid_to', models.DateTimeField(blank=True, null=True)), + ('discount_value_type', models.CharField(choices=[('fixed', 'USD'), ('percentage', '%')], default='fixed', max_length=10)), + ('discount_value', models.DecimalField(decimal_places=2, max_digits=12)), + ('products', models.ManyToManyField(blank=True, to='core.Product')), + ], + options={ + 'ordering': ('code',), + }, + ), + ] diff --git a/src/core/migrations/__init__.py b/src/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/models.py b/src/core/models.py new file mode 100644 index 0000000..b5c191b --- /dev/null +++ b/src/core/models.py @@ -0,0 +1,221 @@ +import logging +from decimal import Decimal +from measurement.measures import Weight + +from django.db import models +from django.db.models.functions import Coalesce +from django.conf import settings +from django.utils import timezone +from django.urls import reverse +from django.core.validators import MinValueValidator, MaxValueValidator + +from django_measurement.models import MeasurementField + +from accounts.models import User, Address + +from . import ( + DiscountValueType, + VoucherType, + TransactionStatus, + OrderStatus, + ShippingMethodType +) +from .weight import WeightUnits, zero_weight + +logger = logging.getLogger(__name__) + + +class ProductManager(models.Manager): + def get_queryset(self): + return super().get_queryset().annotate( + num_ordered=models.Count('order_lines__quantity', distinct=True) + ) + + + +class Product(models.Model): + name = models.CharField(max_length=250) + description = models.TextField(blank=True) + sku = models.CharField(max_length=255, unique=True) + price = models.DecimalField( + max_digits=settings.DEFAULT_MAX_DIGITS, + decimal_places=settings.DEFAULT_DECIMAL_PLACES, + blank=True, + null=True, + ) + weight = MeasurementField( + measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True + ) + + visible_in_listings = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = ProductManager() + + def __str__(self): + return self.name + + +class ProductPhoto(models.Model): + product = models.ForeignKey(Product, on_delete=models.CASCADE) + image = models.ImageField(upload_to='products/images') + + def __str__(self): + return self.product.name + + + + +class Coupon(models.Model): + type = models.CharField( + max_length=20, choices=VoucherType.CHOICES, default=VoucherType.ENTIRE_ORDER + ) + name = models.CharField(max_length=255, null=True, blank=True) + code = models.CharField(max_length=12, unique=True, db_index=True) + valid_from = models.DateTimeField(default=timezone.now) + valid_to = models.DateTimeField(null=True, blank=True) + + discount_value_type = models.CharField( + max_length=10, + choices=DiscountValueType.CHOICES, + default=DiscountValueType.FIXED, + ) + discount_value = models.DecimalField( + max_digits=settings.DEFAULT_MAX_DIGITS, + decimal_places=settings.DEFAULT_DECIMAL_PLACES, + ) + + products = models.ManyToManyField(Product, blank=True) + + class Meta: + ordering = ("code",) + + @property + def is_valid(self): + today = timezone.localtime(timezone.now()).date() + return True if today >= self.valid_from and today <= self.valid_to else False + + + + +class ShippingMethod(models.Model): + name = models.CharField(max_length=100) + type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES) + + + + +class Order(models.Model): + customer = models.ForeignKey( + User, + related_name="orders", + on_delete=models.SET_NULL, + null=True + ) + status = models.CharField( + max_length=32, default=OrderStatus.UNFULFILLED, choices=OrderStatus.CHOICES + ) + billing_address = models.ForeignKey( + Address, + related_name="+", + editable=False, + null=True, + on_delete=models.SET_NULL, + ) + shipping_address = models.ForeignKey( + Address, + related_name="+", + editable=False, + null=True, + on_delete=models.SET_NULL, + ) + shipping_method = models.ForeignKey( + ShippingMethod, + blank=True, + null=True, + related_name="orders", + on_delete=models.SET_NULL, + ) + + total_net_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + ) + + + weight = MeasurementField( + measurement=Weight, + unit_choices=WeightUnits.CHOICES, + default=zero_weight, + ) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True) + + def get_total_quantity(self): + return sum([line.quantity for line in self]) + + def get_absolute_url(self): + return reverse('dashboard:order-detail', kwargs={'pk': self.pk}) + +class Transaction(models.Model): + status = models.CharField( + max_length=32, + blank=True, + default=TransactionStatus.CREATED, + choices=TransactionStatus.CHOICES + ) + paypal_id = models.CharField(max_length=64, blank=True) + confirmation_email_sent = models.BooleanField(default=False) + order = models.OneToOneField( + Order, + editable=False, + blank=True, + null=True, + on_delete=models.CASCADE + ) + + +class OrderLine(models.Model): + order = models.ForeignKey( + Order, + related_name="lines", + editable=False, + on_delete=models.CASCADE + ) + product = models.ForeignKey( + Product, + related_name="order_lines", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + quantity = models.IntegerField(validators=[MinValueValidator(1)]) + quantity_fulfilled = models.IntegerField( + validators=[MinValueValidator(0)], default=0 + ) + customer_note = models.TextField(blank=True, default="") + + currency = models.CharField( + max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, + default=settings.DEFAULT_CURRENCY, + ) + + unit_price = models.DecimalField( + max_digits=settings.DEFAULT_MAX_DIGITS, + decimal_places=settings.DEFAULT_DECIMAL_PLACES, + ) + + tax_rate = models.DecimalField( + max_digits=5, decimal_places=2, default=Decimal("0.0") + ) + + def get_total(self): + return self.unit_price * self.quantity + + @property + def quantity_unfulfilled(self): + return self.quantity - self.quantity_fulfilled diff --git a/src/core/signals.py b/src/core/signals.py new file mode 100644 index 0000000..98f76d7 --- /dev/null +++ b/src/core/signals.py @@ -0,0 +1,34 @@ +import logging +from io import BytesIO + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from . import TransactionStatus +from .models import Order, Transaction +from .tasks import send_order_confirmation_email + +logger = logging.getLogger(__name__) + +@receiver(post_save, sender=Order, dispatch_uid="order_created") +def order_created(sender, instance, created, **kwargs): + if created: + logger.info("Order was created") + Transaction.objects.create(order=instance) + + +@receiver(post_save, sender=Transaction, dispatch_uid="transaction_created") +def transaction_created(sender, instance, created, **kwargs): + if created: + logger.info("Transaction was created") + + elif instance.status == TransactionStatus.COMPLETED and not instance.confirmation_email_sent: + # TODO: change order to order.values() + order = { + 'order_id': instance.order.pk, + 'email': instance.order.customer.email, + 'full_name': instance.order.customer.get_full_name() + } + send_order_confirmation_email.delay(order) + instance.confirmation_email_sent = True + instance.save() diff --git a/src/core/tasks.py b/src/core/tasks.py new file mode 100644 index 0000000..0211068 --- /dev/null +++ b/src/core/tasks.py @@ -0,0 +1,26 @@ +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 .models import Order + +logger = get_task_logger(__name__) + + +CONFIRM_ORDER_TEMPLATE = 'storefront/order_confirmation' +ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' +ORDER_REFUND_TEMPLATE = 'storefront/order_refund' + +@shared_task(name='send_order_confirmation_email') +def send_order_confirmation_email(order): + send_templated_mail( + template_name=CONFIRM_ORDER_TEMPLATE, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[order['email']], + context=order + ) + + logger.info(f"Order confirmation email sent to {order['email']}") diff --git a/src/core/tests.py b/src/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/core/views.py b/src/core/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/src/core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/core/weight.py b/src/core/weight.py new file mode 100644 index 0000000..27a01e5 --- /dev/null +++ b/src/core/weight.py @@ -0,0 +1,40 @@ +from measurement.measures import Weight + +class WeightUnits: + KILOGRAM = "kg" + POUND = "lb" + OUNCE = "oz" + GRAM = "g" + + CHOICES = [ + (KILOGRAM, "kg"), + (POUND, "lb"), + (OUNCE, "oz"), + (GRAM, "g"), + ] + + +def zero_weight(): + """Represent the zero weight value.""" + return Weight(kg=0) + + +def convert_weight(weight: Weight, unit: str) -> Weight: + """Covert weight to given unit and round it to 3 digits after decimal point.""" + # Weight amount from the Weight instance can be retrived in serveral units + # via its properties. eg. Weight(lb=10).kg + converted_weight = getattr(weight, unit) + weight = Weight(**{unit: converted_weight}) + weight.value = round(weight.value, 3) + return weight + + +def convert_weight_to_default_weight_unit(weight: Weight) -> Weight: + """Weight is kept in one unit, but should be returned in site default unit.""" + default_unit = get_default_weight_unit() + if weight is not None: + if weight.unit != default_unit: + weight = convert_weight(weight, default_unit) + else: + weight.value = round(weight.value, 3) + return weight diff --git a/src/dashboard/__init__.py b/src/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dashboard/admin.py b/src/dashboard/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/src/dashboard/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/src/dashboard/apps.py b/src/dashboard/apps.py new file mode 100644 index 0000000..7b1cc05 --- /dev/null +++ b/src/dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'dashboard' diff --git a/src/dashboard/forms.py b/src/dashboard/forms.py new file mode 100644 index 0000000..13a8394 --- /dev/null +++ b/src/dashboard/forms.py @@ -0,0 +1,27 @@ +import logging +from django import forms + +from core.models import Order, OrderLine, ShippingMethod + +logger = logging.getLogger(__name__) + +class OrderLineFulfillForm(forms.ModelForm): + # send_shipment_details_to_customer = forms.BooleanField(initial=True) + + class Meta: + model = OrderLine + fields = ('quantity_fulfilled',) + widgets = { + 'quantity_fulfilled': forms.NumberInput(attrs = { + 'min': 0, + }) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['quantity_fulfilled'].widget.attrs['max'] = self.instance.quantity + +OrderLineFormset = forms.inlineformset_factory( + Order, OrderLine, form=OrderLineFulfillForm, + extra=0, can_delete=False +) diff --git a/src/dashboard/migrations/__init__.py b/src/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dashboard/models.py b/src/dashboard/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/src/dashboard/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/src/dashboard/templates/dashboard/customer_detail.html b/src/dashboard/templates/dashboard/customer_detail.html new file mode 100644 index 0000000..88c7534 --- /dev/null +++ b/src/dashboard/templates/dashboard/customer_detail.html @@ -0,0 +1,76 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Customer: {{customer.get_full_name}}

+ Edit +
+
+
+

Info

+
+
+ Email address
+ {{customer.email}} +
+
+ Default shipping address
+ {% with shipping_address=customer.default_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 + {% endwith %} +
+
+ Other addresses
+ {% for address in customer.addresses.all %} +

+

+ {{address.first_name}} + {{address.last_name}}
+ {{address.street_address_1}}
+ {% if address.street_address_2 %} + {{address.street_address_2}}
+ {% endif %} + {{address.city}}, {{address.state}}, {{address.postal_code}} +
+ 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 %} +
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/customer_form.html b/src/dashboard/templates/dashboard/customer_form.html new file mode 100644 index 0000000..7df14ac --- /dev/null +++ b/src/dashboard/templates/dashboard/customer_form.html @@ -0,0 +1,16 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+

Update Customer

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

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/customer_list.html b/src/dashboard/templates/dashboard/customer_list.html new file mode 100644 index 0000000..223eaa8 --- /dev/null +++ b/src/dashboard/templates/dashboard/customer_list.html @@ -0,0 +1,24 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} + +{% endblock content %} diff --git a/src/dashboard/templates/dashboard/dashboard_detail.html b/src/dashboard/templates/dashboard/dashboard_detail.html new file mode 100644 index 0000000..828a38f --- /dev/null +++ b/src/dashboard/templates/dashboard/dashboard_detail.html @@ -0,0 +1,25 @@ +{% extends "dashboard.html" %} +{% load static %} +{% load tz %} + +{% block content %} + +{% endblock %} diff --git a/src/dashboard/templates/dashboard/order_detail.html b/src/dashboard/templates/dashboard/order_detail.html new file mode 100644 index 0000000..8e9075c --- /dev/null +++ b/src/dashboard/templates/dashboard/order_detail.html @@ -0,0 +1,82 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Order #{{order.pk}}

+ {{order.get_status_display}} +
+
+
+ Product + SKU + Quantity + Price + Total +
+ {% for item in order.lines.all %} +
+ {% with product=item.product %} +
+ {{product.productphoto_set.first.image}} +
{{product.name}}
+
+ {{product.sku}} + {{item.quantity}} + ${{product.price}} + ${{item.get_total}} + {% endwith %} +
+ {% empty %} +

No items in order yet.

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

Customer

+
+ {% with customer=order.customer %} +
+

+ {{customer.get_full_name}}
+ View Profile +

+
+
+ Email address
+ {{customer.email}} +
+
+ Shipping address
+ {% with shipping_address=order.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}} +
+ {% endwith %} +
+ {% endwith %} +
+ +
+
+

PayPal

+
+
+

Transaction: {{order.transaction.paypal_id}}
+ Status: {{order.transaction.get_status_display}} +

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

Fulfill Order #{{order.pk}}

+
+
+ {% csrf_token %} + {{ form.management_form }} + +
+ {% for dict in form.errors %} + {% for error in dict.values %} +
+ {{ error }} +
+ {% endfor %} + {% endfor %} +
+ Product + SKU + Quantity to fulfill + Grind +
+ {% for form in form %} +
+ {% with product=form.instance.product %} + {{form.id}} +
+ {{product.productphoto_set.first.image}} +
{{product.name}}
+
+ {{product.sku}} + {{form.quantity_fulfilled}} / {{form.instance.quantity}} + {{form.instance.customer_note}} + {% endwith %} +
+ {% endfor %} +
+ cancel +
+
+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/order_list.html b/src/dashboard/templates/dashboard/order_list.html new file mode 100644 index 0000000..6c08a12 --- /dev/null +++ b/src/dashboard/templates/dashboard/order_list.html @@ -0,0 +1,32 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} + +{% endblock content %} diff --git a/src/dashboard/templates/dashboard/product_create_form.html b/src/dashboard/templates/dashboard/product_create_form.html new file mode 100644 index 0000000..93af236 --- /dev/null +++ b/src/dashboard/templates/dashboard/product_create_form.html @@ -0,0 +1,16 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+

Create product

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

+ or cancel +

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

Product: {{product.name}}

+ Edit +
+
+
+ {{product.productphoto_set.first.image}} +
+
+

{{product.name}}

+

{{product.description}}

+

${{product.price}}

+

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

+

Ordered {{num_ordered}} times.

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/product_list.html b/src/dashboard/templates/dashboard/product_list.html new file mode 100644 index 0000000..a22e198 --- /dev/null +++ b/src/dashboard/templates/dashboard/product_list.html @@ -0,0 +1,29 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} + +{% endblock content %} diff --git a/src/dashboard/tests.py b/src/dashboard/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/dashboard/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py new file mode 100644 index 0000000..24d3ef8 --- /dev/null +++ b/src/dashboard/urls.py @@ -0,0 +1,29 @@ +from django.urls import path, include +from . import views + +urlpatterns = [ + path('', views.DashboardHomeView.as_view(), name='home'), + + path('orders/', views.OrderListView.as_view(), name='order-list'), + path('orders//', include([ + path('', views.OrderDetailView.as_view(), name='order-detail'), + # 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('products/', views.ProductListView.as_view(), name='product-list'), + path('products/new/', views.ProductCreateView.as_view(), name='product-create'), + path('/', 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('customers/', views.CustomerListView.as_view(), name='customer-list'), + path('customers//', include([ + path('', views.CustomerDetailView.as_view(), name='customer-detail'), + path('update/', views.CustomerUpdateView.as_view(), name='customer-update'), + # path('delete/', views.CustomerDeleteView.as_view(), name='customer-delete'), + ])), +] diff --git a/src/dashboard/views.py b/src/dashboard/views.py new file mode 100644 index 0000000..45138ac --- /dev/null +++ b/src/dashboard/views.py @@ -0,0 +1,183 @@ +import logging +from datetime import datetime +from django.conf import settings +from django.utils import timezone +from django.shortcuts import render, reverse, redirect, get_object_or_404 +from django.http import HttpResponseRedirect +from django.urls import reverse, reverse_lazy +from django.views.generic.base import RedirectView, TemplateView +from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView, FormMixin +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 +from django.forms import inlineformset_factory + +from django.db.models import ( + Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value +) +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 import DiscountValueType, VoucherType, OrderStatus, ShippingMethodType +from .forms import OrderLineFulfillForm, OrderLineFormset + +logger = logging.getLogger(__name__) + +class DashboardHomeView(TemplateView): + template_name = 'dashboard/dashboard_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + today = timezone.localtime(timezone.now()).date() + context['order_count'] = Order.objects.filter( + created_at__date=today + ).count() + context['orders_unfulfilled'] = Order.objects.filter( + status=OrderStatus.UNFULFILLED + ).count() + context['todays_sales'] = Order.objects.filter( + created_at__date=today + ).aggregate(total=Sum('total_net_amount'))['total'] or 0 + return context + +class OrderListView(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: + object_list = Order.objects.filter( + status=query + ).order_by( + '-created_at' + ).select_related( + 'customer' + ) + + else: + object_list = Order.objects.order_by( + '-created_at' + ).select_related( + 'customer' + ) + + return object_list + +class OrderDetailView(DetailView): + model = Order + template_name = 'dashboard/order_detail.html' + + def get_object(self): + queryset = Order.objects.filter( + pk=self.kwargs.get(self.pk_url_kwarg) + ).select_related( + 'customer', + 'billing_address', + 'shipping_address', + 'shipping_method' + ).prefetch_related( + 'lines__product__productphoto_set' + ) + obj = queryset.get() + return obj + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + return context + + +class OrderFulfillView(UpdateView): + model = Order + template_name = "dashboard/order_fulfill.html" + form_class = OrderLineFormset + + 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}) + + +def order_fulfill(request, pk): + order = Order.objects.get(pk=pk) + OrderLineFormset = inlineformset_factory(Order, OrderLine, form=OrderLineFulfillForm, extra=0, can_delete=False) + if request.method == "POST": + formset = OrderLineFormset(request.POST, request.FILES, instance=order) + if formset.is_valid(): + formset.save() + # Do something. Should generally end with a redirect. For example: + return HttpResponseRedirect(order.get_absolute_url()) + + else: + formset = OrderLineFormset(instance=order) + return render(request, 'dashboard/order_fulfill.html', {'formset': formset, 'order': order}) + + +class ProductListView(ListView): + model = Product + template_name = 'dashboard/product_list.html' + + # def get_queryset(self): + # object_list = Product.objects.filter( + # status=OrderStatus.UNFULFILLED + # ).select_related( + # 'customer' + # ) + # return object_list + +class ProductDetailView(DetailView): + model = Product + template_name = 'dashboard/product_detail.html' + +class ProductCreateView(CreateView): + model = Product + template_name = "dashboard/product_create_form.html" + fields = '__all__' + + +class CustomerListView(ListView): + model = User + template_name = 'dashboard/customer_list.html' + + def get_queryset(self): + object_list = User.objects.filter( + Exists( + Order.objects.filter(customer=OuterRef('pk')) + ) | Q(is_staff=False) + ).prefetch_related( + 'orders' + ).annotate( + num_orders=Count('orders') + ) + + return object_list + +class CustomerDetailView(DetailView): + model = User + template_name = 'dashboard/customer_detail.html' + context_object_name = 'customer' + +class CustomerUpdateView(UpdateView): + model = User + template_name = 'dashboard/customer_form.html' + context_object_name = 'customer' + fields = ( + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'addresses', + 'default_shipping_address' + ) + + def get_success_url(self): + return reverse('dashboard:customer-detail', kwargs={'pk': self.object.pk}) + diff --git a/src/manage.py b/src/manage.py new file mode 100755 index 0000000..99c5c15 --- /dev/null +++ b/src/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ptcoffee.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/src/media/products/images/coffee.jpg b/src/media/products/images/coffee.jpg new file mode 100644 index 0000000..545e3d7 Binary files /dev/null and b/src/media/products/images/coffee.jpg differ diff --git a/src/media/products/images/coffee_02.jpg b/src/media/products/images/coffee_02.jpg new file mode 100644 index 0000000..1f55562 Binary files /dev/null and b/src/media/products/images/coffee_02.jpg differ diff --git a/src/media/products/images/coffee_05.jpg b/src/media/products/images/coffee_05.jpg new file mode 100644 index 0000000..2285280 Binary files /dev/null and b/src/media/products/images/coffee_05.jpg differ diff --git a/src/media/products/images/dantes_tornado.png b/src/media/products/images/dantes_tornado.png new file mode 100644 index 0000000..30d5438 Binary files /dev/null and b/src/media/products/images/dantes_tornado.png differ diff --git a/src/media/products/images/decaf.png b/src/media/products/images/decaf.png new file mode 100644 index 0000000..5de7e2a Binary files /dev/null and b/src/media/products/images/decaf.png differ diff --git a/src/media/products/images/loop_d_loop.png b/src/media/products/images/loop_d_loop.png new file mode 100644 index 0000000..b9ad617 Binary files /dev/null and b/src/media/products/images/loop_d_loop.png differ diff --git a/src/media/products/images/moka_java.png b/src/media/products/images/moka_java.png new file mode 100644 index 0000000..d8ca2c8 Binary files /dev/null and b/src/media/products/images/moka_java.png differ diff --git a/src/media/products/images/nicaragua.png b/src/media/products/images/nicaragua.png new file mode 100644 index 0000000..095dee4 Binary files /dev/null and b/src/media/products/images/nicaragua.png differ diff --git a/src/media/products/images/pantomime.png b/src/media/products/images/pantomime.png new file mode 100644 index 0000000..b255f9c Binary files /dev/null and b/src/media/products/images/pantomime.png differ diff --git a/src/media/products/images/slice1.png b/src/media/products/images/slice1.png new file mode 100644 index 0000000..7224adb Binary files /dev/null and b/src/media/products/images/slice1.png differ diff --git a/src/media/products/images/slice2.png b/src/media/products/images/slice2.png new file mode 100644 index 0000000..00b6f30 Binary files /dev/null and b/src/media/products/images/slice2.png differ diff --git a/src/ptcoffee/__init__.py b/src/ptcoffee/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/src/ptcoffee/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/src/ptcoffee/asgi.py b/src/ptcoffee/asgi.py new file mode 100644 index 0000000..8d8aba9 --- /dev/null +++ b/src/ptcoffee/asgi.py @@ -0,0 +1,11 @@ +""" +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ptcoffee.settings') + +application = get_asgi_application() diff --git a/src/ptcoffee/celery.py b/src/ptcoffee/celery.py new file mode 100644 index 0000000..b0bcde2 --- /dev/null +++ b/src/ptcoffee/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery +from django.conf import settings + +app = Celery('ptcoffee') + +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/src/ptcoffee/config.py b/src/ptcoffee/config.py new file mode 100644 index 0000000..69041e1 --- /dev/null +++ b/src/ptcoffee/config.py @@ -0,0 +1,30 @@ +from dotenv import load_dotenv +import os + +load_dotenv() + +DEBUG = os.environ.get('DEBUG', True) + +DATABASE_CONFIG = { + 'ENGINE' : 'django.db.backends.postgresql', + 'OPTIONS': { + 'service': 'pg_service', + 'passfile': '.pgpass' + } +} +SECRET_KEY = os.environ.get('SECRET_KEY', '') +CACHE_CONFIG = { + 'LOCATION' : 'redis://127.0.0.1:6379', + 'BACKEND' : 'django.core.cache.backends.redis.RedisCache', +} + +PAYPAL_CLIENT_ID = os.environ.get('PAYPAL_CLIENT_ID', '') +PAYPAL_SECRET_ID = os.environ.get('PAYPAL_SECRET_ID', '') + +ANYMAIL_CONFIG = { + 'MAILGUN_API_KEY': os.environ.get('MAILGUN_API_KEY', ''), + 'MAILGUN_SENDER_DOMAIN': os.environ.get('MAILGUN_SENDER_DOMAIN', '') +} + +SERVER_EMAIL = os.environ.get('SERVER_EMAIL', '') +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', '') diff --git a/src/ptcoffee/middleware.py b/src/ptcoffee/middleware.py new file mode 100644 index 0000000..ed4378a --- /dev/null +++ b/src/ptcoffee/middleware.py @@ -0,0 +1,45 @@ +import zoneinfo +from urllib import parse +from celery.utils.log import get_task_logger +from inspect import getmodule +import dashboard.views +from django.http import Http404 + + +logger = get_task_logger(__name__) + +from django.utils import timezone + +class TimezoneMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + tzname = request.COOKIES.get('timezone') + if tzname: + tzname = parse.unquote(tzname) + timezone.activate(zoneinfo.ZoneInfo(tzname)) + else: + timezone.deactivate() + return self.get_response(request) + + + +class RestrictStaffToAdminMiddleware: + """ + A middleware that restricts staff members access to administration panels. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + module = getmodule(view_func) + if (module is dashboard.views) and (not request.user.is_staff): + ip = request.META.get('HTTP_X_REAL_IP', request.META.get('REMOTE_ADDR')) + ua = request.META.get('HTTP_USER_AGENT') + logger.warn(f'Non-staff user "{request.user}" attempted to access admin site at "{request.get_full_path()}". UA = "{ua}", IP = "{ip}", Method = {request.method}') + raise Http404 diff --git a/src/ptcoffee/settings.py b/src/ptcoffee/settings.py new file mode 100644 index 0000000..997d76b --- /dev/null +++ b/src/ptcoffee/settings.py @@ -0,0 +1,218 @@ +import os +from pathlib import Path +from django.urls import reverse_lazy +from .config import * + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Add Your Required Allow Host +ALLOWED_HOSTS = ["*"] + +INTERNAL_IPS = [ + '127.0.0.1', + 'localhost', +] + +# Application definition +INSTALLED_APPS = [ + # Core + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # 3rd Party + 'django_filters', + 'storages', + 'debug_toolbar', + 'django_celery_beat', + 'django_celery_results', + 'anymail', + 'compressor', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + + # Local + 'accounts.apps.AccountsConfig', + 'core.apps.CoreConfig', + 'storefront.apps.StorefrontConfig', + 'dashboard.apps.DashboardConfig', +] + +# Middlewares +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'ptcoffee.middleware.TimezoneMiddleware', + 'ptcoffee.middleware.RestrictStaffToAdminMiddleware', + 'debug_toolbar.middleware.DebugToolbarMiddleware', +] + +ROOT_URLCONF = 'ptcoffee.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'storefront.context_processors.cart', + ], + }, + }, +] + +WSGI_APPLICATION = 'ptcoffee.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + 'default': DATABASE_CONFIG +} + +CACHES = {'default': CACHE_CONFIG} + +SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' + +# Password validation +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +AUTH_USER_MODEL = 'accounts.User' +LOGIN_REDIRECT_URL = reverse_lazy('storefront:product-list') + +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True +ACCOUNT_SESSION_REMEMBER = True +ACCOUNT_AUTHENTICATION_METHOD = 'email' +ACCOUNT_UNIQUE_EMAIL = True + + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'public' +STATICFILES_DIRS = [BASE_DIR / 'static'] +MEDIA_URL = '/images/' +MEDIA_ROOT = BASE_DIR / 'media' + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'compressor.finders.CompressorFinder', +) + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +# Email +EMAIL_BACKEND = 'anymail.backends.mailgun.EmailBackend' + +ANYMAIL = ANYMAIL_CONFIG + +ADMINS = ( + ('Nathan Chapman', 'debug@nathanjchapman.com'), +) + +MANAGERS = ADMINS + +TEMPLATED_EMAIL_BACKEND = 'templated_email.backends.vanilla_django.TemplateBackend' + + +# Site ID +SITE_ID = 1 + +# Logging +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s %(levelname)s %(name)s %(message)s' + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, +} + +CART_SESSION_ID = 'cart' + +DEFAULT_COUNTRY = 'US' +DEFAULT_CURRENCY = 'USD' +DEFAULT_DECIMAL_PLACES = 2 +DEFAULT_MAX_DIGITS = 12 +DEFAULT_CURRENCY_CODE_LENGTH = 3 + + +# Celery - prefix with CELERY_ +CELERY_BROKER_URL = CACHE_CONFIG['LOCATION'] +CELERY_RESULT_BACKEND = CELERY_BROKER_URL +CELERY_CACHE_BACKEND = CELERY_BROKER_URL +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TASK_TRACK_STARTED = True +CELERY_TIMEZONE = 'US/Mountain' diff --git a/src/ptcoffee/urls.py b/src/ptcoffee/urls.py new file mode 100644 index 0000000..d583c5f --- /dev/null +++ b/src/ptcoffee/urls.py @@ -0,0 +1,17 @@ +import debug_toolbar +from django.conf import settings +from django.contrib import admin +from django.urls import include, path +from django.conf.urls.static import static + +urlpatterns = [ + path('', include(('storefront.urls', 'storefront'), namespace='storefront')), + path('dashboard/', include(('dashboard.urls', 'dashboard'), namespace='dashboard')), + path('accounts/', include('allauth.urls')), + path('accounts/', include(('accounts.urls', 'accounts'), namespace='accounts')), + # path('accounts/', include('django.contrib.auth.urls')), + path('admin/', admin.site.urls), + path('__debug__/', include('debug_toolbar.urls')), +] +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/src/ptcoffee/wsgi.py b/src/ptcoffee/wsgi.py new file mode 100644 index 0000000..40307fc --- /dev/null +++ b/src/ptcoffee/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ptcoffee.settings') + +application = get_wsgi_application() diff --git a/src/static/images/box.png b/src/static/images/box.png new file mode 100644 index 0000000..5d237a1 Binary files /dev/null and b/src/static/images/box.png differ diff --git a/src/static/images/coffee_banner.jpeg b/src/static/images/coffee_banner.jpeg new file mode 100644 index 0000000..03cd426 Binary files /dev/null and b/src/static/images/coffee_banner.jpeg differ diff --git a/src/static/images/coupon.png b/src/static/images/coupon.png new file mode 100644 index 0000000..73787bb Binary files /dev/null and b/src/static/images/coupon.png differ diff --git a/src/static/images/cubes.png b/src/static/images/cubes.png new file mode 100644 index 0000000..8c8af6f Binary files /dev/null and b/src/static/images/cubes.png differ diff --git a/src/static/images/customer.png b/src/static/images/customer.png new file mode 100644 index 0000000..e56c87e Binary files /dev/null and b/src/static/images/customer.png differ diff --git a/src/static/images/fair_trade_stamp.png b/src/static/images/fair_trade_stamp.png new file mode 100644 index 0000000..9bc0f2e Binary files /dev/null and b/src/static/images/fair_trade_stamp.png differ diff --git a/src/static/images/gear.png b/src/static/images/gear.png new file mode 100644 index 0000000..b69cf82 Binary files /dev/null and b/src/static/images/gear.png differ diff --git a/src/static/images/keep_calm.jpg b/src/static/images/keep_calm.jpg new file mode 100644 index 0000000..40e74b7 Binary files /dev/null and b/src/static/images/keep_calm.jpg differ diff --git a/src/static/images/shopping_cart.svg b/src/static/images/shopping_cart.svg new file mode 100644 index 0000000..784de2f --- /dev/null +++ b/src/static/images/shopping_cart.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/src/static/images/store.png b/src/static/images/store.png new file mode 100644 index 0000000..c2d33b3 Binary files /dev/null and b/src/static/images/store.png differ diff --git a/src/static/scripts/controllers/cart_controller.js b/src/static/scripts/controllers/cart_controller.js new file mode 100644 index 0000000..4e2a909 --- /dev/null +++ b/src/static/scripts/controllers/cart_controller.js @@ -0,0 +1,60 @@ +import { getCookie, setCookie } from "../lib/cookie.js" +import { Controller } from "../stimulus.js" + +export default class extends Controller { + static get targets() { + return [ "cartList" ] + } + + connect() { + console.log(getCookie('cart')) + this.cart = this.getOrSetCart() + this.element.addEventListener('addToCart', this.addItem.bind(this)) + } + + getOrSetCart() { + let cart = JSON.parse(getCookie('cart')) + if (cart === null) { + console.log('created cart cookie') + setCookie('cart', '{}') + cart = {} + } else { + Object.keys(cart).forEach((item_id) => { + fetch(`/${item_id}/`) + .then((response) => response.text()) + .then((html) => { + this.cartListTarget.innerHTML += html; + }); + }) + } + return cart + } + + decodeCartItems(cart) { + + } + + addItem(event) { + if (this.cart[event.detail.item] === undefined) { + this.cart[event.detail.item] = {'quantity': 1} + } else { + this.cart[event.detail.item]['quantity'] += 1 + } + setCookie('cart', JSON.stringify(this.cart)) + console.log(this.cart) + + fetch(`/${event.detail.item}/`) + .then((response) => response.text()) + .then((html) => { + this.cartListTarget.innerHTML += html; + }); + } + + removeItem() { + + } + + refresh() { + + } +} \ No newline at end of file diff --git a/src/static/scripts/controllers/cartitem_controller.js b/src/static/scripts/controllers/cartitem_controller.js new file mode 100644 index 0000000..9160e00 --- /dev/null +++ b/src/static/scripts/controllers/cartitem_controller.js @@ -0,0 +1,15 @@ +import { getCookie, setCookie } from "../lib/cookie.js" +import { Controller } from "../stimulus.js" + +export default class extends Controller { + static get targets() { + return [ "qty" ] + } + + connect() { + console.log(this.qtyTarget) + + } + + +} \ No newline at end of file diff --git a/src/static/scripts/controllers/product_controller.js b/src/static/scripts/controllers/product_controller.js new file mode 100644 index 0000000..a73f119 --- /dev/null +++ b/src/static/scripts/controllers/product_controller.js @@ -0,0 +1,23 @@ +// import getCookie from "../get_cookie.js" +import { Controller } from "../stimulus.js" + +export default class extends Controller { + static values = { url: String } + + connect() { + this.event = new CustomEvent('addToCart', { + detail: { + item: this.urlValue + } + }) + } + + start() { + + } + + addToCart(event) { + event.preventDefault() + window.dispatchEvent(this.event) + } +} \ No newline at end of file diff --git a/src/static/scripts/index.js b/src/static/scripts/index.js new file mode 100644 index 0000000..0f6ffc1 --- /dev/null +++ b/src/static/scripts/index.js @@ -0,0 +1,10 @@ +import { Application } from "./stimulus.js" + +import CartController from "./controllers/cart_controller.js" +import CartitemController from "./controllers/cartitem_controller.js" +import ProductController from "./controllers/product_controller.js" + +const application = Application.start() +application.register("cart", CartController) +application.register("cartitem", CartitemController) +application.register("product", ProductController) diff --git a/src/static/scripts/initializers/timezone.js b/src/static/scripts/initializers/timezone.js new file mode 100644 index 0000000..a0a793a --- /dev/null +++ b/src/static/scripts/initializers/timezone.js @@ -0,0 +1,4 @@ +import { setCookie } from '../lib/cookie.js' + +const { timeZone } = new Intl.DateTimeFormat().resolvedOptions() +setCookie('timezone', timeZone) diff --git a/src/static/scripts/payment.js b/src/static/scripts/payment.js new file mode 100644 index 0000000..89fdd20 --- /dev/null +++ b/src/static/scripts/payment.js @@ -0,0 +1,158 @@ +import { getCookie } from "./lib/cookie.js" + +let form = document.querySelector('form.order__form') + +// Render the PayPal button into #paypal-button-container +paypal.Buttons({ + + // Call your server to set up the transaction + createOrder: function(data, actions) { + const formData = new FormData(form) + + // get the csrftoken + const csrftoken = getCookie("csrftoken") + + const options = { + method: "POST", + body: new URLSearchParams(formData), + mode: "same-origin", + }; + + // construct a new Request passing in the csrftoken + const request = new Request('/checkout/', { + headers: { "X-CSRFToken": csrftoken }, + }) + + return fetch(request, options) + .then(function(res) { + return res.json(); + }).then(function(orderData) { + return orderData.id; + }); + }, + + // Call your server to finalize the transaction + onApprove: function(data, actions) { + const csrftoken = getCookie("csrftoken") + return fetch('/paypal/order/' + data.orderID + '/capture/', { + method: 'post', + headers: {'X-CSRFToken': csrftoken} + }).then(function(res) { + return res.json(); + }).then(function(orderData) { + var errorDetail = Array.isArray(orderData.details) && orderData.details[0]; + + if (errorDetail && errorDetail.issue === 'INSTRUMENT_DECLINED') { + return actions.restart(); // Recoverable state, per: + // https://developer.paypal.com/docs/checkout/integration-features/funding-failure/ + } + + if (errorDetail) { + var msg = 'Sorry, your transaction could not be processed.'; + if (errorDetail.description) msg += '\n\n' + errorDetail.description; + if (orderData.debug_id) msg += ' (' + orderData.debug_id + ')'; + // Show a failure message + return actions.redirect(orderData.redirect_urls['cancel_url']) + } + + // Successful capture! + actions.redirect(orderData.redirect_urls['return_url']) + }); + } +}).render('#paypal-button-container'); + + + +// RETURNED DATA +// { +// "id": "1WM83684A69628456", +// "status": "COMPLETED", +// "purchase_units": [ +// { +// "reference_id": "default", +// "shipping": { +// "name": { +// "full_name": "John Doe" +// }, +// "address": { +// "address_line_1": "1 Main St", +// "admin_area_2": "San Jose", +// "admin_area_1": "CA", +// "postal_code": "95131", +// "country_code": "US" +// } +// }, +// "payments": { +// "captures": [ +// { +// "id": "9AU23265T5630860D", +// "status": "COMPLETED", +// "amount": { +// "currency_code": "USD", +// "value": "13.40" +// }, +// "final_capture": true, +// "seller_protection": { +// "status": "ELIGIBLE", +// "dispute_categories": [ +// "ITEM_NOT_RECEIVED", +// "UNAUTHORIZED_TRANSACTION" +// ] +// }, +// "seller_receivable_breakdown": { +// "gross_amount": { +// "currency_code": "USD", +// "value": "13.40" +// }, +// "paypal_fee": { +// "currency_code": "USD", +// "value": "0.96" +// }, +// "net_amount": { +// "currency_code": "USD", +// "value": "12.44" +// } +// }, +// "links": [ +// { +// "href": "https://api.sandbox.paypal.com/v2/payments/captures/9AU23265T5630860D", +// "rel": "self", +// "method": "GET" +// }, +// { +// "href": "https://api.sandbox.paypal.com/v2/payments/captures/9AU23265T5630860D/refund", +// "rel": "refund", +// "method": "POST" +// }, +// { +// "href": "https://api.sandbox.paypal.com/v2/checkout/orders/1WM83684A69628456", +// "rel": "up", +// "method": "GET" +// } +// ], +// "create_time": "2022-02-28T15:33:07Z", +// "update_time": "2022-02-28T15:33:07Z" +// } +// ] +// } +// } +// ], +// "payer": { +// "name": { +// "given_name": "John", +// "surname": "Doe" +// }, +// "email_address": "sb-rlst914027742@personal.example.com", +// "payer_id": "G9RGHQ72CGKF6", +// "address": { +// "country_code": "US" +// } +// }, +// "links": [ +// { +// "href": "https://api.sandbox.paypal.com/v2/checkout/orders/1WM83684A69628456", +// "rel": "self", +// "method": "GET" +// } +// ] +// } diff --git a/src/static/scripts/stimulus.js b/src/static/scripts/stimulus.js new file mode 100644 index 0000000..dd0f992 --- /dev/null +++ b/src/static/scripts/stimulus.js @@ -0,0 +1,1944 @@ +/* +Stimulus 3.0.1 +Copyright © 2021 Basecamp, LLC + */ +class EventListener { + constructor(eventTarget, eventName, eventOptions) { + this.eventTarget = eventTarget; + this.eventName = eventName; + this.eventOptions = eventOptions; + this.unorderedBindings = new Set(); + } + connect() { + this.eventTarget.addEventListener(this.eventName, this, this.eventOptions); + } + disconnect() { + this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions); + } + bindingConnected(binding) { + this.unorderedBindings.add(binding); + } + bindingDisconnected(binding) { + this.unorderedBindings.delete(binding); + } + handleEvent(event) { + const extendedEvent = extendEvent(event); + for (const binding of this.bindings) { + if (extendedEvent.immediatePropagationStopped) { + break; + } + else { + binding.handleEvent(extendedEvent); + } + } + } + get bindings() { + return Array.from(this.unorderedBindings).sort((left, right) => { + const leftIndex = left.index, rightIndex = right.index; + return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0; + }); + } +} +function extendEvent(event) { + if ("immediatePropagationStopped" in event) { + return event; + } + else { + const { stopImmediatePropagation } = event; + return Object.assign(event, { + immediatePropagationStopped: false, + stopImmediatePropagation() { + this.immediatePropagationStopped = true; + stopImmediatePropagation.call(this); + } + }); + } +} + +class Dispatcher { + constructor(application) { + this.application = application; + this.eventListenerMaps = new Map; + this.started = false; + } + start() { + if (!this.started) { + this.started = true; + this.eventListeners.forEach(eventListener => eventListener.connect()); + } + } + stop() { + if (this.started) { + this.started = false; + this.eventListeners.forEach(eventListener => eventListener.disconnect()); + } + } + get eventListeners() { + return Array.from(this.eventListenerMaps.values()) + .reduce((listeners, map) => listeners.concat(Array.from(map.values())), []); + } + bindingConnected(binding) { + this.fetchEventListenerForBinding(binding).bindingConnected(binding); + } + bindingDisconnected(binding) { + this.fetchEventListenerForBinding(binding).bindingDisconnected(binding); + } + handleError(error, message, detail = {}) { + this.application.handleError(error, `Error ${message}`, detail); + } + fetchEventListenerForBinding(binding) { + const { eventTarget, eventName, eventOptions } = binding; + return this.fetchEventListener(eventTarget, eventName, eventOptions); + } + fetchEventListener(eventTarget, eventName, eventOptions) { + const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget); + const cacheKey = this.cacheKey(eventName, eventOptions); + let eventListener = eventListenerMap.get(cacheKey); + if (!eventListener) { + eventListener = this.createEventListener(eventTarget, eventName, eventOptions); + eventListenerMap.set(cacheKey, eventListener); + } + return eventListener; + } + createEventListener(eventTarget, eventName, eventOptions) { + const eventListener = new EventListener(eventTarget, eventName, eventOptions); + if (this.started) { + eventListener.connect(); + } + return eventListener; + } + fetchEventListenerMapForEventTarget(eventTarget) { + let eventListenerMap = this.eventListenerMaps.get(eventTarget); + if (!eventListenerMap) { + eventListenerMap = new Map; + this.eventListenerMaps.set(eventTarget, eventListenerMap); + } + return eventListenerMap; + } + cacheKey(eventName, eventOptions) { + const parts = [eventName]; + Object.keys(eventOptions).sort().forEach(key => { + parts.push(`${eventOptions[key] ? "" : "!"}${key}`); + }); + return parts.join(":"); + } +} + +const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/; +function parseActionDescriptorString(descriptorString) { + const source = descriptorString.trim(); + const matches = source.match(descriptorPattern) || []; + return { + eventTarget: parseEventTarget(matches[4]), + eventName: matches[2], + eventOptions: matches[9] ? parseEventOptions(matches[9]) : {}, + identifier: matches[5], + methodName: matches[7] + }; +} +function parseEventTarget(eventTargetName) { + if (eventTargetName == "window") { + return window; + } + else if (eventTargetName == "document") { + return document; + } +} +function parseEventOptions(eventOptions) { + return eventOptions.split(":").reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {}); +} +function stringifyEventTarget(eventTarget) { + if (eventTarget == window) { + return "window"; + } + else if (eventTarget == document) { + return "document"; + } +} + +function camelize(value) { + return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()); +} +function capitalize(value) { + return value.charAt(0).toUpperCase() + value.slice(1); +} +function dasherize(value) { + return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`); +} +function tokenize(value) { + return value.match(/[^\s]+/g) || []; +} + +class Action { + constructor(element, index, descriptor) { + this.element = element; + this.index = index; + this.eventTarget = descriptor.eventTarget || element; + this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name"); + this.eventOptions = descriptor.eventOptions || {}; + this.identifier = descriptor.identifier || error("missing identifier"); + this.methodName = descriptor.methodName || error("missing method name"); + } + static forToken(token) { + return new this(token.element, token.index, parseActionDescriptorString(token.content)); + } + toString() { + const eventNameSuffix = this.eventTargetName ? `@${this.eventTargetName}` : ""; + return `${this.eventName}${eventNameSuffix}->${this.identifier}#${this.methodName}`; + } + get params() { + if (this.eventTarget instanceof Element) { + return this.getParamsFromEventTargetAttributes(this.eventTarget); + } + else { + return {}; + } + } + getParamsFromEventTargetAttributes(eventTarget) { + const params = {}; + const pattern = new RegExp(`^data-${this.identifier}-(.+)-param$`); + const attributes = Array.from(eventTarget.attributes); + attributes.forEach(({ name, value }) => { + const match = name.match(pattern); + const key = match && match[1]; + if (key) { + Object.assign(params, { [camelize(key)]: typecast(value) }); + } + }); + return params; + } + get eventTargetName() { + return stringifyEventTarget(this.eventTarget); + } +} +const defaultEventNames = { + "a": e => "click", + "button": e => "click", + "form": e => "submit", + "details": e => "toggle", + "input": e => e.getAttribute("type") == "submit" ? "click" : "input", + "select": e => "change", + "textarea": e => "input" +}; +function getDefaultEventNameForElement(element) { + const tagName = element.tagName.toLowerCase(); + if (tagName in defaultEventNames) { + return defaultEventNames[tagName](element); + } +} +function error(message) { + throw new Error(message); +} +function typecast(value) { + try { + return JSON.parse(value); + } + catch (o_O) { + return value; + } +} + +class Binding { + constructor(context, action) { + this.context = context; + this.action = action; + } + get index() { + return this.action.index; + } + get eventTarget() { + return this.action.eventTarget; + } + get eventOptions() { + return this.action.eventOptions; + } + get identifier() { + return this.context.identifier; + } + handleEvent(event) { + if (this.willBeInvokedByEvent(event)) { + this.invokeWithEvent(event); + } + } + get eventName() { + return this.action.eventName; + } + get method() { + const method = this.controller[this.methodName]; + if (typeof method == "function") { + return method; + } + throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`); + } + invokeWithEvent(event) { + const { target, currentTarget } = event; + try { + const { params } = this.action; + const actionEvent = Object.assign(event, { params }); + this.method.call(this.controller, actionEvent); + this.context.logDebugActivity(this.methodName, { event, target, currentTarget, action: this.methodName }); + } + catch (error) { + const { identifier, controller, element, index } = this; + const detail = { identifier, controller, element, index, event }; + this.context.handleError(error, `invoking action "${this.action}"`, detail); + } + } + willBeInvokedByEvent(event) { + const eventTarget = event.target; + if (this.element === eventTarget) { + return true; + } + else if (eventTarget instanceof Element && this.element.contains(eventTarget)) { + return this.scope.containsElement(eventTarget); + } + else { + return this.scope.containsElement(this.action.element); + } + } + get controller() { + return this.context.controller; + } + get methodName() { + return this.action.methodName; + } + get element() { + return this.scope.element; + } + get scope() { + return this.context.scope; + } +} + +class ElementObserver { + constructor(element, delegate) { + this.mutationObserverInit = { attributes: true, childList: true, subtree: true }; + this.element = element; + this.started = false; + this.delegate = delegate; + this.elements = new Set; + this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)); + } + start() { + if (!this.started) { + this.started = true; + this.mutationObserver.observe(this.element, this.mutationObserverInit); + this.refresh(); + } + } + pause(callback) { + if (this.started) { + this.mutationObserver.disconnect(); + this.started = false; + } + callback(); + if (!this.started) { + this.mutationObserver.observe(this.element, this.mutationObserverInit); + this.started = true; + } + } + stop() { + if (this.started) { + this.mutationObserver.takeRecords(); + this.mutationObserver.disconnect(); + this.started = false; + } + } + refresh() { + if (this.started) { + const matches = new Set(this.matchElementsInTree()); + for (const element of Array.from(this.elements)) { + if (!matches.has(element)) { + this.removeElement(element); + } + } + for (const element of Array.from(matches)) { + this.addElement(element); + } + } + } + processMutations(mutations) { + if (this.started) { + for (const mutation of mutations) { + this.processMutation(mutation); + } + } + } + processMutation(mutation) { + if (mutation.type == "attributes") { + this.processAttributeChange(mutation.target, mutation.attributeName); + } + else if (mutation.type == "childList") { + this.processRemovedNodes(mutation.removedNodes); + this.processAddedNodes(mutation.addedNodes); + } + } + processAttributeChange(node, attributeName) { + const element = node; + if (this.elements.has(element)) { + if (this.delegate.elementAttributeChanged && this.matchElement(element)) { + this.delegate.elementAttributeChanged(element, attributeName); + } + else { + this.removeElement(element); + } + } + else if (this.matchElement(element)) { + this.addElement(element); + } + } + processRemovedNodes(nodes) { + for (const node of Array.from(nodes)) { + const element = this.elementFromNode(node); + if (element) { + this.processTree(element, this.removeElement); + } + } + } + processAddedNodes(nodes) { + for (const node of Array.from(nodes)) { + const element = this.elementFromNode(node); + if (element && this.elementIsActive(element)) { + this.processTree(element, this.addElement); + } + } + } + matchElement(element) { + return this.delegate.matchElement(element); + } + matchElementsInTree(tree = this.element) { + return this.delegate.matchElementsInTree(tree); + } + processTree(tree, processor) { + for (const element of this.matchElementsInTree(tree)) { + processor.call(this, element); + } + } + elementFromNode(node) { + if (node.nodeType == Node.ELEMENT_NODE) { + return node; + } + } + elementIsActive(element) { + if (element.isConnected != this.element.isConnected) { + return false; + } + else { + return this.element.contains(element); + } + } + addElement(element) { + if (!this.elements.has(element)) { + if (this.elementIsActive(element)) { + this.elements.add(element); + if (this.delegate.elementMatched) { + this.delegate.elementMatched(element); + } + } + } + } + removeElement(element) { + if (this.elements.has(element)) { + this.elements.delete(element); + if (this.delegate.elementUnmatched) { + this.delegate.elementUnmatched(element); + } + } + } +} + +class AttributeObserver { + constructor(element, attributeName, delegate) { + this.attributeName = attributeName; + this.delegate = delegate; + this.elementObserver = new ElementObserver(element, this); + } + get element() { + return this.elementObserver.element; + } + get selector() { + return `[${this.attributeName}]`; + } + start() { + this.elementObserver.start(); + } + pause(callback) { + this.elementObserver.pause(callback); + } + stop() { + this.elementObserver.stop(); + } + refresh() { + this.elementObserver.refresh(); + } + get started() { + return this.elementObserver.started; + } + matchElement(element) { + return element.hasAttribute(this.attributeName); + } + matchElementsInTree(tree) { + const match = this.matchElement(tree) ? [tree] : []; + const matches = Array.from(tree.querySelectorAll(this.selector)); + return match.concat(matches); + } + elementMatched(element) { + if (this.delegate.elementMatchedAttribute) { + this.delegate.elementMatchedAttribute(element, this.attributeName); + } + } + elementUnmatched(element) { + if (this.delegate.elementUnmatchedAttribute) { + this.delegate.elementUnmatchedAttribute(element, this.attributeName); + } + } + elementAttributeChanged(element, attributeName) { + if (this.delegate.elementAttributeValueChanged && this.attributeName == attributeName) { + this.delegate.elementAttributeValueChanged(element, attributeName); + } + } +} + +class StringMapObserver { + constructor(element, delegate) { + this.element = element; + this.delegate = delegate; + this.started = false; + this.stringMap = new Map; + this.mutationObserver = new MutationObserver(mutations => this.processMutations(mutations)); + } + start() { + if (!this.started) { + this.started = true; + this.mutationObserver.observe(this.element, { attributes: true, attributeOldValue: true }); + this.refresh(); + } + } + stop() { + if (this.started) { + this.mutationObserver.takeRecords(); + this.mutationObserver.disconnect(); + this.started = false; + } + } + refresh() { + if (this.started) { + for (const attributeName of this.knownAttributeNames) { + this.refreshAttribute(attributeName, null); + } + } + } + processMutations(mutations) { + if (this.started) { + for (const mutation of mutations) { + this.processMutation(mutation); + } + } + } + processMutation(mutation) { + const attributeName = mutation.attributeName; + if (attributeName) { + this.refreshAttribute(attributeName, mutation.oldValue); + } + } + refreshAttribute(attributeName, oldValue) { + const key = this.delegate.getStringMapKeyForAttribute(attributeName); + if (key != null) { + if (!this.stringMap.has(attributeName)) { + this.stringMapKeyAdded(key, attributeName); + } + const value = this.element.getAttribute(attributeName); + if (this.stringMap.get(attributeName) != value) { + this.stringMapValueChanged(value, key, oldValue); + } + if (value == null) { + const oldValue = this.stringMap.get(attributeName); + this.stringMap.delete(attributeName); + if (oldValue) + this.stringMapKeyRemoved(key, attributeName, oldValue); + } + else { + this.stringMap.set(attributeName, value); + } + } + } + stringMapKeyAdded(key, attributeName) { + if (this.delegate.stringMapKeyAdded) { + this.delegate.stringMapKeyAdded(key, attributeName); + } + } + stringMapValueChanged(value, key, oldValue) { + if (this.delegate.stringMapValueChanged) { + this.delegate.stringMapValueChanged(value, key, oldValue); + } + } + stringMapKeyRemoved(key, attributeName, oldValue) { + if (this.delegate.stringMapKeyRemoved) { + this.delegate.stringMapKeyRemoved(key, attributeName, oldValue); + } + } + get knownAttributeNames() { + return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames))); + } + get currentAttributeNames() { + return Array.from(this.element.attributes).map(attribute => attribute.name); + } + get recordedAttributeNames() { + return Array.from(this.stringMap.keys()); + } +} + +function add(map, key, value) { + fetch(map, key).add(value); +} +function del(map, key, value) { + fetch(map, key).delete(value); + prune(map, key); +} +function fetch(map, key) { + let values = map.get(key); + if (!values) { + values = new Set(); + map.set(key, values); + } + return values; +} +function prune(map, key) { + const values = map.get(key); + if (values != null && values.size == 0) { + map.delete(key); + } +} + +class Multimap { + constructor() { + this.valuesByKey = new Map(); + } + get keys() { + return Array.from(this.valuesByKey.keys()); + } + get values() { + const sets = Array.from(this.valuesByKey.values()); + return sets.reduce((values, set) => values.concat(Array.from(set)), []); + } + get size() { + const sets = Array.from(this.valuesByKey.values()); + return sets.reduce((size, set) => size + set.size, 0); + } + add(key, value) { + add(this.valuesByKey, key, value); + } + delete(key, value) { + del(this.valuesByKey, key, value); + } + has(key, value) { + const values = this.valuesByKey.get(key); + return values != null && values.has(value); + } + hasKey(key) { + return this.valuesByKey.has(key); + } + hasValue(value) { + const sets = Array.from(this.valuesByKey.values()); + return sets.some(set => set.has(value)); + } + getValuesForKey(key) { + const values = this.valuesByKey.get(key); + return values ? Array.from(values) : []; + } + getKeysForValue(value) { + return Array.from(this.valuesByKey) + .filter(([key, values]) => values.has(value)) + .map(([key, values]) => key); + } +} + +class IndexedMultimap extends Multimap { + constructor() { + super(); + this.keysByValue = new Map; + } + get values() { + return Array.from(this.keysByValue.keys()); + } + add(key, value) { + super.add(key, value); + add(this.keysByValue, value, key); + } + delete(key, value) { + super.delete(key, value); + del(this.keysByValue, value, key); + } + hasValue(value) { + return this.keysByValue.has(value); + } + getKeysForValue(value) { + const set = this.keysByValue.get(value); + return set ? Array.from(set) : []; + } +} + +class TokenListObserver { + constructor(element, attributeName, delegate) { + this.attributeObserver = new AttributeObserver(element, attributeName, this); + this.delegate = delegate; + this.tokensByElement = new Multimap; + } + get started() { + return this.attributeObserver.started; + } + start() { + this.attributeObserver.start(); + } + pause(callback) { + this.attributeObserver.pause(callback); + } + stop() { + this.attributeObserver.stop(); + } + refresh() { + this.attributeObserver.refresh(); + } + get element() { + return this.attributeObserver.element; + } + get attributeName() { + return this.attributeObserver.attributeName; + } + elementMatchedAttribute(element) { + this.tokensMatched(this.readTokensForElement(element)); + } + elementAttributeValueChanged(element) { + const [unmatchedTokens, matchedTokens] = this.refreshTokensForElement(element); + this.tokensUnmatched(unmatchedTokens); + this.tokensMatched(matchedTokens); + } + elementUnmatchedAttribute(element) { + this.tokensUnmatched(this.tokensByElement.getValuesForKey(element)); + } + tokensMatched(tokens) { + tokens.forEach(token => this.tokenMatched(token)); + } + tokensUnmatched(tokens) { + tokens.forEach(token => this.tokenUnmatched(token)); + } + tokenMatched(token) { + this.delegate.tokenMatched(token); + this.tokensByElement.add(token.element, token); + } + tokenUnmatched(token) { + this.delegate.tokenUnmatched(token); + this.tokensByElement.delete(token.element, token); + } + refreshTokensForElement(element) { + const previousTokens = this.tokensByElement.getValuesForKey(element); + const currentTokens = this.readTokensForElement(element); + const firstDifferingIndex = zip(previousTokens, currentTokens) + .findIndex(([previousToken, currentToken]) => !tokensAreEqual(previousToken, currentToken)); + if (firstDifferingIndex == -1) { + return [[], []]; + } + else { + return [previousTokens.slice(firstDifferingIndex), currentTokens.slice(firstDifferingIndex)]; + } + } + readTokensForElement(element) { + const attributeName = this.attributeName; + const tokenString = element.getAttribute(attributeName) || ""; + return parseTokenString(tokenString, element, attributeName); + } +} +function parseTokenString(tokenString, element, attributeName) { + return tokenString.trim().split(/\s+/).filter(content => content.length) + .map((content, index) => ({ element, attributeName, content, index })); +} +function zip(left, right) { + const length = Math.max(left.length, right.length); + return Array.from({ length }, (_, index) => [left[index], right[index]]); +} +function tokensAreEqual(left, right) { + return left && right && left.index == right.index && left.content == right.content; +} + +class ValueListObserver { + constructor(element, attributeName, delegate) { + this.tokenListObserver = new TokenListObserver(element, attributeName, this); + this.delegate = delegate; + this.parseResultsByToken = new WeakMap; + this.valuesByTokenByElement = new WeakMap; + } + get started() { + return this.tokenListObserver.started; + } + start() { + this.tokenListObserver.start(); + } + stop() { + this.tokenListObserver.stop(); + } + refresh() { + this.tokenListObserver.refresh(); + } + get element() { + return this.tokenListObserver.element; + } + get attributeName() { + return this.tokenListObserver.attributeName; + } + tokenMatched(token) { + const { element } = token; + const { value } = this.fetchParseResultForToken(token); + if (value) { + this.fetchValuesByTokenForElement(element).set(token, value); + this.delegate.elementMatchedValue(element, value); + } + } + tokenUnmatched(token) { + const { element } = token; + const { value } = this.fetchParseResultForToken(token); + if (value) { + this.fetchValuesByTokenForElement(element).delete(token); + this.delegate.elementUnmatchedValue(element, value); + } + } + fetchParseResultForToken(token) { + let parseResult = this.parseResultsByToken.get(token); + if (!parseResult) { + parseResult = this.parseToken(token); + this.parseResultsByToken.set(token, parseResult); + } + return parseResult; + } + fetchValuesByTokenForElement(element) { + let valuesByToken = this.valuesByTokenByElement.get(element); + if (!valuesByToken) { + valuesByToken = new Map; + this.valuesByTokenByElement.set(element, valuesByToken); + } + return valuesByToken; + } + parseToken(token) { + try { + const value = this.delegate.parseValueForToken(token); + return { value }; + } + catch (error) { + return { error }; + } + } +} + +class BindingObserver { + constructor(context, delegate) { + this.context = context; + this.delegate = delegate; + this.bindingsByAction = new Map; + } + start() { + if (!this.valueListObserver) { + this.valueListObserver = new ValueListObserver(this.element, this.actionAttribute, this); + this.valueListObserver.start(); + } + } + stop() { + if (this.valueListObserver) { + this.valueListObserver.stop(); + delete this.valueListObserver; + this.disconnectAllActions(); + } + } + get element() { + return this.context.element; + } + get identifier() { + return this.context.identifier; + } + get actionAttribute() { + return this.schema.actionAttribute; + } + get schema() { + return this.context.schema; + } + get bindings() { + return Array.from(this.bindingsByAction.values()); + } + connectAction(action) { + const binding = new Binding(this.context, action); + this.bindingsByAction.set(action, binding); + this.delegate.bindingConnected(binding); + } + disconnectAction(action) { + const binding = this.bindingsByAction.get(action); + if (binding) { + this.bindingsByAction.delete(action); + this.delegate.bindingDisconnected(binding); + } + } + disconnectAllActions() { + this.bindings.forEach(binding => this.delegate.bindingDisconnected(binding)); + this.bindingsByAction.clear(); + } + parseValueForToken(token) { + const action = Action.forToken(token); + if (action.identifier == this.identifier) { + return action; + } + } + elementMatchedValue(element, action) { + this.connectAction(action); + } + elementUnmatchedValue(element, action) { + this.disconnectAction(action); + } +} + +class ValueObserver { + constructor(context, receiver) { + this.context = context; + this.receiver = receiver; + this.stringMapObserver = new StringMapObserver(this.element, this); + this.valueDescriptorMap = this.controller.valueDescriptorMap; + this.invokeChangedCallbacksForDefaultValues(); + } + start() { + this.stringMapObserver.start(); + } + stop() { + this.stringMapObserver.stop(); + } + get element() { + return this.context.element; + } + get controller() { + return this.context.controller; + } + getStringMapKeyForAttribute(attributeName) { + if (attributeName in this.valueDescriptorMap) { + return this.valueDescriptorMap[attributeName].name; + } + } + stringMapKeyAdded(key, attributeName) { + const descriptor = this.valueDescriptorMap[attributeName]; + if (!this.hasValue(key)) { + this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), descriptor.writer(descriptor.defaultValue)); + } + } + stringMapValueChanged(value, name, oldValue) { + const descriptor = this.valueDescriptorNameMap[name]; + if (value === null) + return; + if (oldValue === null) { + oldValue = descriptor.writer(descriptor.defaultValue); + } + this.invokeChangedCallback(name, value, oldValue); + } + stringMapKeyRemoved(key, attributeName, oldValue) { + const descriptor = this.valueDescriptorNameMap[key]; + if (this.hasValue(key)) { + this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), oldValue); + } + else { + this.invokeChangedCallback(key, descriptor.writer(descriptor.defaultValue), oldValue); + } + } + invokeChangedCallbacksForDefaultValues() { + for (const { key, name, defaultValue, writer } of this.valueDescriptors) { + if (defaultValue != undefined && !this.controller.data.has(key)) { + this.invokeChangedCallback(name, writer(defaultValue), undefined); + } + } + } + invokeChangedCallback(name, rawValue, rawOldValue) { + const changedMethodName = `${name}Changed`; + const changedMethod = this.receiver[changedMethodName]; + if (typeof changedMethod == "function") { + const descriptor = this.valueDescriptorNameMap[name]; + const value = descriptor.reader(rawValue); + let oldValue = rawOldValue; + if (rawOldValue) { + oldValue = descriptor.reader(rawOldValue); + } + changedMethod.call(this.receiver, value, oldValue); + } + } + get valueDescriptors() { + const { valueDescriptorMap } = this; + return Object.keys(valueDescriptorMap).map(key => valueDescriptorMap[key]); + } + get valueDescriptorNameMap() { + const descriptors = {}; + Object.keys(this.valueDescriptorMap).forEach(key => { + const descriptor = this.valueDescriptorMap[key]; + descriptors[descriptor.name] = descriptor; + }); + return descriptors; + } + hasValue(attributeName) { + const descriptor = this.valueDescriptorNameMap[attributeName]; + const hasMethodName = `has${capitalize(descriptor.name)}`; + return this.receiver[hasMethodName]; + } +} + +class TargetObserver { + constructor(context, delegate) { + this.context = context; + this.delegate = delegate; + this.targetsByName = new Multimap; + } + start() { + if (!this.tokenListObserver) { + this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this); + this.tokenListObserver.start(); + } + } + stop() { + if (this.tokenListObserver) { + this.disconnectAllTargets(); + this.tokenListObserver.stop(); + delete this.tokenListObserver; + } + } + tokenMatched({ element, content: name }) { + if (this.scope.containsElement(element)) { + this.connectTarget(element, name); + } + } + tokenUnmatched({ element, content: name }) { + this.disconnectTarget(element, name); + } + connectTarget(element, name) { + var _a; + if (!this.targetsByName.has(name, element)) { + this.targetsByName.add(name, element); + (_a = this.tokenListObserver) === null || _a === void 0 ? void 0 : _a.pause(() => this.delegate.targetConnected(element, name)); + } + } + disconnectTarget(element, name) { + var _a; + if (this.targetsByName.has(name, element)) { + this.targetsByName.delete(name, element); + (_a = this.tokenListObserver) === null || _a === void 0 ? void 0 : _a.pause(() => this.delegate.targetDisconnected(element, name)); + } + } + disconnectAllTargets() { + for (const name of this.targetsByName.keys) { + for (const element of this.targetsByName.getValuesForKey(name)) { + this.disconnectTarget(element, name); + } + } + } + get attributeName() { + return `data-${this.context.identifier}-target`; + } + get element() { + return this.context.element; + } + get scope() { + return this.context.scope; + } +} + +class Context { + constructor(module, scope) { + this.logDebugActivity = (functionName, detail = {}) => { + const { identifier, controller, element } = this; + detail = Object.assign({ identifier, controller, element }, detail); + this.application.logDebugActivity(this.identifier, functionName, detail); + }; + this.module = module; + this.scope = scope; + this.controller = new module.controllerConstructor(this); + this.bindingObserver = new BindingObserver(this, this.dispatcher); + this.valueObserver = new ValueObserver(this, this.controller); + this.targetObserver = new TargetObserver(this, this); + try { + this.controller.initialize(); + this.logDebugActivity("initialize"); + } + catch (error) { + this.handleError(error, "initializing controller"); + } + } + connect() { + this.bindingObserver.start(); + this.valueObserver.start(); + this.targetObserver.start(); + try { + this.controller.connect(); + this.logDebugActivity("connect"); + } + catch (error) { + this.handleError(error, "connecting controller"); + } + } + disconnect() { + try { + this.controller.disconnect(); + this.logDebugActivity("disconnect"); + } + catch (error) { + this.handleError(error, "disconnecting controller"); + } + this.targetObserver.stop(); + this.valueObserver.stop(); + this.bindingObserver.stop(); + } + get application() { + return this.module.application; + } + get identifier() { + return this.module.identifier; + } + get schema() { + return this.application.schema; + } + get dispatcher() { + return this.application.dispatcher; + } + get element() { + return this.scope.element; + } + get parentElement() { + return this.element.parentElement; + } + handleError(error, message, detail = {}) { + const { identifier, controller, element } = this; + detail = Object.assign({ identifier, controller, element }, detail); + this.application.handleError(error, `Error ${message}`, detail); + } + targetConnected(element, name) { + this.invokeControllerMethod(`${name}TargetConnected`, element); + } + targetDisconnected(element, name) { + this.invokeControllerMethod(`${name}TargetDisconnected`, element); + } + invokeControllerMethod(methodName, ...args) { + const controller = this.controller; + if (typeof controller[methodName] == "function") { + controller[methodName](...args); + } + } +} + +function readInheritableStaticArrayValues(constructor, propertyName) { + const ancestors = getAncestorsForConstructor(constructor); + return Array.from(ancestors.reduce((values, constructor) => { + getOwnStaticArrayValues(constructor, propertyName).forEach(name => values.add(name)); + return values; + }, new Set)); +} +function readInheritableStaticObjectPairs(constructor, propertyName) { + const ancestors = getAncestorsForConstructor(constructor); + return ancestors.reduce((pairs, constructor) => { + pairs.push(...getOwnStaticObjectPairs(constructor, propertyName)); + return pairs; + }, []); +} +function getAncestorsForConstructor(constructor) { + const ancestors = []; + while (constructor) { + ancestors.push(constructor); + constructor = Object.getPrototypeOf(constructor); + } + return ancestors.reverse(); +} +function getOwnStaticArrayValues(constructor, propertyName) { + const definition = constructor[propertyName]; + return Array.isArray(definition) ? definition : []; +} +function getOwnStaticObjectPairs(constructor, propertyName) { + const definition = constructor[propertyName]; + return definition ? Object.keys(definition).map(key => [key, definition[key]]) : []; +} + +function bless(constructor) { + return shadow(constructor, getBlessedProperties(constructor)); +} +function shadow(constructor, properties) { + const shadowConstructor = extend(constructor); + const shadowProperties = getShadowProperties(constructor.prototype, properties); + Object.defineProperties(shadowConstructor.prototype, shadowProperties); + return shadowConstructor; +} +function getBlessedProperties(constructor) { + const blessings = readInheritableStaticArrayValues(constructor, "blessings"); + return blessings.reduce((blessedProperties, blessing) => { + const properties = blessing(constructor); + for (const key in properties) { + const descriptor = blessedProperties[key] || {}; + blessedProperties[key] = Object.assign(descriptor, properties[key]); + } + return blessedProperties; + }, {}); +} +function getShadowProperties(prototype, properties) { + return getOwnKeys(properties).reduce((shadowProperties, key) => { + const descriptor = getShadowedDescriptor(prototype, properties, key); + if (descriptor) { + Object.assign(shadowProperties, { [key]: descriptor }); + } + return shadowProperties; + }, {}); +} +function getShadowedDescriptor(prototype, properties, key) { + const shadowingDescriptor = Object.getOwnPropertyDescriptor(prototype, key); + const shadowedByValue = shadowingDescriptor && "value" in shadowingDescriptor; + if (!shadowedByValue) { + const descriptor = Object.getOwnPropertyDescriptor(properties, key).value; + if (shadowingDescriptor) { + descriptor.get = shadowingDescriptor.get || descriptor.get; + descriptor.set = shadowingDescriptor.set || descriptor.set; + } + return descriptor; + } +} +const getOwnKeys = (() => { + if (typeof Object.getOwnPropertySymbols == "function") { + return (object) => [ + ...Object.getOwnPropertyNames(object), + ...Object.getOwnPropertySymbols(object) + ]; + } + else { + return Object.getOwnPropertyNames; + } +})(); +const extend = (() => { + function extendWithReflect(constructor) { + function extended() { + return Reflect.construct(constructor, arguments, new.target); + } + extended.prototype = Object.create(constructor.prototype, { + constructor: { value: extended } + }); + Reflect.setPrototypeOf(extended, constructor); + return extended; + } + function testReflectExtension() { + const a = function () { this.a.call(this); }; + const b = extendWithReflect(a); + b.prototype.a = function () { }; + return new b; + } + try { + testReflectExtension(); + return extendWithReflect; + } + catch (error) { + return (constructor) => class extended extends constructor { + }; + } +})(); + +function blessDefinition(definition) { + return { + identifier: definition.identifier, + controllerConstructor: bless(definition.controllerConstructor) + }; +} + +class Module { + constructor(application, definition) { + this.application = application; + this.definition = blessDefinition(definition); + this.contextsByScope = new WeakMap; + this.connectedContexts = new Set; + } + get identifier() { + return this.definition.identifier; + } + get controllerConstructor() { + return this.definition.controllerConstructor; + } + get contexts() { + return Array.from(this.connectedContexts); + } + connectContextForScope(scope) { + const context = this.fetchContextForScope(scope); + this.connectedContexts.add(context); + context.connect(); + } + disconnectContextForScope(scope) { + const context = this.contextsByScope.get(scope); + if (context) { + this.connectedContexts.delete(context); + context.disconnect(); + } + } + fetchContextForScope(scope) { + let context = this.contextsByScope.get(scope); + if (!context) { + context = new Context(this, scope); + this.contextsByScope.set(scope, context); + } + return context; + } +} + +class ClassMap { + constructor(scope) { + this.scope = scope; + } + has(name) { + return this.data.has(this.getDataKey(name)); + } + get(name) { + return this.getAll(name)[0]; + } + getAll(name) { + const tokenString = this.data.get(this.getDataKey(name)) || ""; + return tokenize(tokenString); + } + getAttributeName(name) { + return this.data.getAttributeNameForKey(this.getDataKey(name)); + } + getDataKey(name) { + return `${name}-class`; + } + get data() { + return this.scope.data; + } +} + +class DataMap { + constructor(scope) { + this.scope = scope; + } + get element() { + return this.scope.element; + } + get identifier() { + return this.scope.identifier; + } + get(key) { + const name = this.getAttributeNameForKey(key); + return this.element.getAttribute(name); + } + set(key, value) { + const name = this.getAttributeNameForKey(key); + this.element.setAttribute(name, value); + return this.get(key); + } + has(key) { + const name = this.getAttributeNameForKey(key); + return this.element.hasAttribute(name); + } + delete(key) { + if (this.has(key)) { + const name = this.getAttributeNameForKey(key); + this.element.removeAttribute(name); + return true; + } + else { + return false; + } + } + getAttributeNameForKey(key) { + return `data-${this.identifier}-${dasherize(key)}`; + } +} + +class Guide { + constructor(logger) { + this.warnedKeysByObject = new WeakMap; + this.logger = logger; + } + warn(object, key, message) { + let warnedKeys = this.warnedKeysByObject.get(object); + if (!warnedKeys) { + warnedKeys = new Set; + this.warnedKeysByObject.set(object, warnedKeys); + } + if (!warnedKeys.has(key)) { + warnedKeys.add(key); + this.logger.warn(message, object); + } + } +} + +function attributeValueContainsToken(attributeName, token) { + return `[${attributeName}~="${token}"]`; +} + +class TargetSet { + constructor(scope) { + this.scope = scope; + } + get element() { + return this.scope.element; + } + get identifier() { + return this.scope.identifier; + } + get schema() { + return this.scope.schema; + } + has(targetName) { + return this.find(targetName) != null; + } + find(...targetNames) { + return targetNames.reduce((target, targetName) => target + || this.findTarget(targetName) + || this.findLegacyTarget(targetName), undefined); + } + findAll(...targetNames) { + return targetNames.reduce((targets, targetName) => [ + ...targets, + ...this.findAllTargets(targetName), + ...this.findAllLegacyTargets(targetName) + ], []); + } + findTarget(targetName) { + const selector = this.getSelectorForTargetName(targetName); + return this.scope.findElement(selector); + } + findAllTargets(targetName) { + const selector = this.getSelectorForTargetName(targetName); + return this.scope.findAllElements(selector); + } + getSelectorForTargetName(targetName) { + const attributeName = this.schema.targetAttributeForScope(this.identifier); + return attributeValueContainsToken(attributeName, targetName); + } + findLegacyTarget(targetName) { + const selector = this.getLegacySelectorForTargetName(targetName); + return this.deprecate(this.scope.findElement(selector), targetName); + } + findAllLegacyTargets(targetName) { + const selector = this.getLegacySelectorForTargetName(targetName); + return this.scope.findAllElements(selector).map(element => this.deprecate(element, targetName)); + } + getLegacySelectorForTargetName(targetName) { + const targetDescriptor = `${this.identifier}.${targetName}`; + return attributeValueContainsToken(this.schema.targetAttribute, targetDescriptor); + } + deprecate(element, targetName) { + if (element) { + const { identifier } = this; + const attributeName = this.schema.targetAttribute; + const revisedAttributeName = this.schema.targetAttributeForScope(identifier); + this.guide.warn(element, `target:${targetName}`, `Please replace ${attributeName}="${identifier}.${targetName}" with ${revisedAttributeName}="${targetName}". ` + + `The ${attributeName} attribute is deprecated and will be removed in a future version of Stimulus.`); + } + return element; + } + get guide() { + return this.scope.guide; + } +} + +class Scope { + constructor(schema, element, identifier, logger) { + this.targets = new TargetSet(this); + this.classes = new ClassMap(this); + this.data = new DataMap(this); + this.containsElement = (element) => { + return element.closest(this.controllerSelector) === this.element; + }; + this.schema = schema; + this.element = element; + this.identifier = identifier; + this.guide = new Guide(logger); + } + findElement(selector) { + return this.element.matches(selector) + ? this.element + : this.queryElements(selector).find(this.containsElement); + } + findAllElements(selector) { + return [ + ...this.element.matches(selector) ? [this.element] : [], + ...this.queryElements(selector).filter(this.containsElement) + ]; + } + queryElements(selector) { + return Array.from(this.element.querySelectorAll(selector)); + } + get controllerSelector() { + return attributeValueContainsToken(this.schema.controllerAttribute, this.identifier); + } +} + +class ScopeObserver { + constructor(element, schema, delegate) { + this.element = element; + this.schema = schema; + this.delegate = delegate; + this.valueListObserver = new ValueListObserver(this.element, this.controllerAttribute, this); + this.scopesByIdentifierByElement = new WeakMap; + this.scopeReferenceCounts = new WeakMap; + } + start() { + this.valueListObserver.start(); + } + stop() { + this.valueListObserver.stop(); + } + get controllerAttribute() { + return this.schema.controllerAttribute; + } + parseValueForToken(token) { + const { element, content: identifier } = token; + const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element); + let scope = scopesByIdentifier.get(identifier); + if (!scope) { + scope = this.delegate.createScopeForElementAndIdentifier(element, identifier); + scopesByIdentifier.set(identifier, scope); + } + return scope; + } + elementMatchedValue(element, value) { + const referenceCount = (this.scopeReferenceCounts.get(value) || 0) + 1; + this.scopeReferenceCounts.set(value, referenceCount); + if (referenceCount == 1) { + this.delegate.scopeConnected(value); + } + } + elementUnmatchedValue(element, value) { + const referenceCount = this.scopeReferenceCounts.get(value); + if (referenceCount) { + this.scopeReferenceCounts.set(value, referenceCount - 1); + if (referenceCount == 1) { + this.delegate.scopeDisconnected(value); + } + } + } + fetchScopesByIdentifierForElement(element) { + let scopesByIdentifier = this.scopesByIdentifierByElement.get(element); + if (!scopesByIdentifier) { + scopesByIdentifier = new Map; + this.scopesByIdentifierByElement.set(element, scopesByIdentifier); + } + return scopesByIdentifier; + } +} + +class Router { + constructor(application) { + this.application = application; + this.scopeObserver = new ScopeObserver(this.element, this.schema, this); + this.scopesByIdentifier = new Multimap; + this.modulesByIdentifier = new Map; + } + get element() { + return this.application.element; + } + get schema() { + return this.application.schema; + } + get logger() { + return this.application.logger; + } + get controllerAttribute() { + return this.schema.controllerAttribute; + } + get modules() { + return Array.from(this.modulesByIdentifier.values()); + } + get contexts() { + return this.modules.reduce((contexts, module) => contexts.concat(module.contexts), []); + } + start() { + this.scopeObserver.start(); + } + stop() { + this.scopeObserver.stop(); + } + loadDefinition(definition) { + this.unloadIdentifier(definition.identifier); + const module = new Module(this.application, definition); + this.connectModule(module); + } + unloadIdentifier(identifier) { + const module = this.modulesByIdentifier.get(identifier); + if (module) { + this.disconnectModule(module); + } + } + getContextForElementAndIdentifier(element, identifier) { + const module = this.modulesByIdentifier.get(identifier); + if (module) { + return module.contexts.find(context => context.element == element); + } + } + handleError(error, message, detail) { + this.application.handleError(error, message, detail); + } + createScopeForElementAndIdentifier(element, identifier) { + return new Scope(this.schema, element, identifier, this.logger); + } + scopeConnected(scope) { + this.scopesByIdentifier.add(scope.identifier, scope); + const module = this.modulesByIdentifier.get(scope.identifier); + if (module) { + module.connectContextForScope(scope); + } + } + scopeDisconnected(scope) { + this.scopesByIdentifier.delete(scope.identifier, scope); + const module = this.modulesByIdentifier.get(scope.identifier); + if (module) { + module.disconnectContextForScope(scope); + } + } + connectModule(module) { + this.modulesByIdentifier.set(module.identifier, module); + const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier); + scopes.forEach(scope => module.connectContextForScope(scope)); + } + disconnectModule(module) { + this.modulesByIdentifier.delete(module.identifier); + const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier); + scopes.forEach(scope => module.disconnectContextForScope(scope)); + } +} + +const defaultSchema = { + controllerAttribute: "data-controller", + actionAttribute: "data-action", + targetAttribute: "data-target", + targetAttributeForScope: identifier => `data-${identifier}-target` +}; + +class Application { + constructor(element = document.documentElement, schema = defaultSchema) { + this.logger = console; + this.debug = false; + this.logDebugActivity = (identifier, functionName, detail = {}) => { + if (this.debug) { + this.logFormattedMessage(identifier, functionName, detail); + } + }; + this.element = element; + this.schema = schema; + this.dispatcher = new Dispatcher(this); + this.router = new Router(this); + } + static start(element, schema) { + const application = new Application(element, schema); + application.start(); + return application; + } + async start() { + await domReady(); + this.logDebugActivity("application", "starting"); + this.dispatcher.start(); + this.router.start(); + this.logDebugActivity("application", "start"); + } + stop() { + this.logDebugActivity("application", "stopping"); + this.dispatcher.stop(); + this.router.stop(); + this.logDebugActivity("application", "stop"); + } + register(identifier, controllerConstructor) { + if (controllerConstructor.shouldLoad) { + this.load({ identifier, controllerConstructor }); + } + } + load(head, ...rest) { + const definitions = Array.isArray(head) ? head : [head, ...rest]; + definitions.forEach(definition => this.router.loadDefinition(definition)); + } + unload(head, ...rest) { + const identifiers = Array.isArray(head) ? head : [head, ...rest]; + identifiers.forEach(identifier => this.router.unloadIdentifier(identifier)); + } + get controllers() { + return this.router.contexts.map(context => context.controller); + } + getControllerForElementAndIdentifier(element, identifier) { + const context = this.router.getContextForElementAndIdentifier(element, identifier); + return context ? context.controller : null; + } + handleError(error, message, detail) { + var _a; + this.logger.error(`%s\n\n%o\n\n%o`, message, error, detail); + (_a = window.onerror) === null || _a === void 0 ? void 0 : _a.call(window, message, "", 0, 0, error); + } + logFormattedMessage(identifier, functionName, detail = {}) { + detail = Object.assign({ application: this }, detail); + this.logger.groupCollapsed(`${identifier} #${functionName}`); + this.logger.log("details:", Object.assign({}, detail)); + this.logger.groupEnd(); + } +} +function domReady() { + return new Promise(resolve => { + if (document.readyState == "loading") { + document.addEventListener("DOMContentLoaded", () => resolve()); + } + else { + resolve(); + } + }); +} + +function ClassPropertiesBlessing(constructor) { + const classes = readInheritableStaticArrayValues(constructor, "classes"); + return classes.reduce((properties, classDefinition) => { + return Object.assign(properties, propertiesForClassDefinition(classDefinition)); + }, {}); +} +function propertiesForClassDefinition(key) { + return { + [`${key}Class`]: { + get() { + const { classes } = this; + if (classes.has(key)) { + return classes.get(key); + } + else { + const attribute = classes.getAttributeName(key); + throw new Error(`Missing attribute "${attribute}"`); + } + } + }, + [`${key}Classes`]: { + get() { + return this.classes.getAll(key); + } + }, + [`has${capitalize(key)}Class`]: { + get() { + return this.classes.has(key); + } + } + }; +} + +function TargetPropertiesBlessing(constructor) { + const targets = readInheritableStaticArrayValues(constructor, "targets"); + return targets.reduce((properties, targetDefinition) => { + return Object.assign(properties, propertiesForTargetDefinition(targetDefinition)); + }, {}); +} +function propertiesForTargetDefinition(name) { + return { + [`${name}Target`]: { + get() { + const target = this.targets.find(name); + if (target) { + return target; + } + else { + throw new Error(`Missing target element "${name}" for "${this.identifier}" controller`); + } + } + }, + [`${name}Targets`]: { + get() { + return this.targets.findAll(name); + } + }, + [`has${capitalize(name)}Target`]: { + get() { + return this.targets.has(name); + } + } + }; +} + +function ValuePropertiesBlessing(constructor) { + const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values"); + const propertyDescriptorMap = { + valueDescriptorMap: { + get() { + return valueDefinitionPairs.reduce((result, valueDefinitionPair) => { + const valueDescriptor = parseValueDefinitionPair(valueDefinitionPair); + const attributeName = this.data.getAttributeNameForKey(valueDescriptor.key); + return Object.assign(result, { [attributeName]: valueDescriptor }); + }, {}); + } + } + }; + return valueDefinitionPairs.reduce((properties, valueDefinitionPair) => { + return Object.assign(properties, propertiesForValueDefinitionPair(valueDefinitionPair)); + }, propertyDescriptorMap); +} +function propertiesForValueDefinitionPair(valueDefinitionPair) { + const definition = parseValueDefinitionPair(valueDefinitionPair); + const { key, name, reader: read, writer: write } = definition; + return { + [name]: { + get() { + const value = this.data.get(key); + if (value !== null) { + return read(value); + } + else { + return definition.defaultValue; + } + }, + set(value) { + if (value === undefined) { + this.data.delete(key); + } + else { + this.data.set(key, write(value)); + } + } + }, + [`has${capitalize(name)}`]: { + get() { + return this.data.has(key) || definition.hasCustomDefaultValue; + } + } + }; +} +function parseValueDefinitionPair([token, typeDefinition]) { + return valueDescriptorForTokenAndTypeDefinition(token, typeDefinition); +} +function parseValueTypeConstant(constant) { + switch (constant) { + case Array: return "array"; + case Boolean: return "boolean"; + case Number: return "number"; + case Object: return "object"; + case String: return "string"; + } +} +function parseValueTypeDefault(defaultValue) { + switch (typeof defaultValue) { + case "boolean": return "boolean"; + case "number": return "number"; + case "string": return "string"; + } + if (Array.isArray(defaultValue)) + return "array"; + if (Object.prototype.toString.call(defaultValue) === "[object Object]") + return "object"; +} +function parseValueTypeObject(typeObject) { + const typeFromObject = parseValueTypeConstant(typeObject.type); + if (typeFromObject) { + const defaultValueType = parseValueTypeDefault(typeObject.default); + if (typeFromObject !== defaultValueType) { + throw new Error(`Type "${typeFromObject}" must match the type of the default value. Given default value: "${typeObject.default}" as "${defaultValueType}"`); + } + return typeFromObject; + } +} +function parseValueTypeDefinition(typeDefinition) { + const typeFromObject = parseValueTypeObject(typeDefinition); + const typeFromDefaultValue = parseValueTypeDefault(typeDefinition); + const typeFromConstant = parseValueTypeConstant(typeDefinition); + const type = typeFromObject || typeFromDefaultValue || typeFromConstant; + if (type) + return type; + throw new Error(`Unknown value type "${typeDefinition}"`); +} +function defaultValueForDefinition(typeDefinition) { + const constant = parseValueTypeConstant(typeDefinition); + if (constant) + return defaultValuesByType[constant]; + const defaultValue = typeDefinition.default; + if (defaultValue !== undefined) + return defaultValue; + return typeDefinition; +} +function valueDescriptorForTokenAndTypeDefinition(token, typeDefinition) { + const key = `${dasherize(token)}-value`; + const type = parseValueTypeDefinition(typeDefinition); + return { + type, + key, + name: camelize(key), + get defaultValue() { return defaultValueForDefinition(typeDefinition); }, + get hasCustomDefaultValue() { return parseValueTypeDefault(typeDefinition) !== undefined; }, + reader: readers[type], + writer: writers[type] || writers.default + }; +} +const defaultValuesByType = { + get array() { return []; }, + boolean: false, + number: 0, + get object() { return {}; }, + string: "" +}; +const readers = { + array(value) { + const array = JSON.parse(value); + if (!Array.isArray(array)) { + throw new TypeError("Expected array"); + } + return array; + }, + boolean(value) { + return !(value == "0" || value == "false"); + }, + number(value) { + return Number(value); + }, + object(value) { + const object = JSON.parse(value); + if (object === null || typeof object != "object" || Array.isArray(object)) { + throw new TypeError("Expected object"); + } + return object; + }, + string(value) { + return value; + } +}; +const writers = { + default: writeString, + array: writeJSON, + object: writeJSON +}; +function writeJSON(value) { + return JSON.stringify(value); +} +function writeString(value) { + return `${value}`; +} + +class Controller { + constructor(context) { + this.context = context; + } + static get shouldLoad() { + return true; + } + get application() { + return this.context.application; + } + get scope() { + return this.context.scope; + } + get element() { + return this.scope.element; + } + get identifier() { + return this.scope.identifier; + } + get targets() { + return this.scope.targets; + } + get classes() { + return this.scope.classes; + } + get data() { + return this.scope.data; + } + initialize() { + } + connect() { + } + disconnect() { + } + dispatch(eventName, { target = this.element, detail = {}, prefix = this.identifier, bubbles = true, cancelable = true } = {}) { + const type = prefix ? `${prefix}:${eventName}` : eventName; + const event = new CustomEvent(type, { detail, bubbles, cancelable }); + target.dispatchEvent(event); + return event; + } +} +Controller.blessings = [ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing]; +Controller.targets = []; +Controller.values = {}; + +export { Application, AttributeObserver, Context, Controller, ElementObserver, IndexedMultimap, Multimap, StringMapObserver, TokenListObserver, ValueListObserver, add, defaultSchema, del, fetch, prune }; diff --git a/src/static/styles/dashboard.css b/src/static/styles/dashboard.css new file mode 100644 index 0000000..78cec7c --- /dev/null +++ b/src/static/styles/dashboard.css @@ -0,0 +1,317 @@ +:root { + --fg-color: #333; + --bg-color: #eff5f8; + --bg-alt-color: #bdc8d2; + --gray-color: #9d9d9d; + --yellow-color: #f8a911; + --yellow-alt-color: #f6c463; + --green-color: #13ce65; +} + +html { + font-size: 100%; +} + +body { + background: var(--bg-color); + font-family: 'Inter', sans-serif; + font-weight: 400; + padding: 0; + margin: 0; + line-height: 1.75; + color: var(--fg-color); +} + +p { + margin-bottom: 1rem; +} + +a { + color: var(--fg-color); +} + +h1, h2, h3, h4, h5 { + margin: 0; + font-family: 'Inter', sans-serif; + font-weight: 700; + line-height: 1.3; +} + +h1 { + font-size: 2.488rem; +} + +h2 { + font-size: 2.074rem; +} + +h3 { + font-size: 1.728rem; +} + +h4 { + font-size: 1.44rem; +} + +h5 { + font-size: 1.2rem; +} + +small { + font-size: 0.833rem; +} + +label { + display: block; +} + + + +input[type=number] { + max-width: 4rem; +} + + +.action-button, +input[type=submit], +button { + font-family: inherit; + font-weight: bold; + font-size: 1rem; + color: var(--fg-color); + text-decoration: none; + background-color: var(--yellow-color); + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: none; + cursor: pointer; +} + +.action-button:hover, +input[type=submit]:hover, +button:hover { + background-color: var(--yellow-alt-color); +} + +figure { + margin: 0; + padding: 0; +} + + +main { + overflow: scroll; + height: 100vh; +} + +main article { + margin: 2rem 2rem 2rem 0; +} + + +.store__info { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2rem; + margin: 1rem 0; +} + +.store__info div { + background-color: white; + padding: 2rem; + border-radius: 0.5rem; +} + +.store__info div h3 { + text-align: right; +} + +.store__action { + background-color: white; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + display: block; + /*font-size: 1.2rem;*/ + font-weight: bold; + text-decoration: none; + font-family: "Inter"; +} + +.store__action:hover { + background-color: var(--bg-alt-color); +} + +.dashboard__main { + display: grid; + grid-template-columns: 1fr 7fr; + gap: 2rem; +} + +.dashboard__sidebar { + height: 100vh; + background-color: var(--bg-alt-color); + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.dashboard__nav { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-top: 1rem; + padding: 1rem; +} + +.dashboard__user { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-top: 1rem; + padding: 1rem; +} + +.dashboard__user a, +.dashboard__nav a { + padding: 0.5rem 1rem; + text-decoration: none; + font-weight: bold; + display: flex; + flex-direction: row; + align-items: center; +} + +.dashboard__nav a img { + width: 24px; + margin-right: 0.5rem; +} + +.dashboard__user a:hover, +.dashboard__nav a:hover { + background-color: var(--bg-color); + border-radius: 0.25rem; +} + + + +.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 { + 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; +} + +.product__image { + max-height: 200px; + border: 0.02rem solid var(--gray-color); +} + + + +.order__fulfill { + grid-column: 8; +} + +.order__status { + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 1.25rem; + font-weight: bold; +} + +.order__status--display { + display: flex; + align-items: center; + flex-direction: row; +} + +.order__status--draft { + background-color: var(--gray-color); +} +.order__status--unfulfilled { + background-color: var(--yellow-alt-color); +} +.order__status--partially_returned { + background-color: var(--gray-color); +} +.order__status--partially_fulfilled { + background-color: var(--gray-color); +} +.order__status--returned { + background-color: var(--gray-color); +} +.order__status--fulfilled { + background-color: var(--green-color); +} +.order__status--cancelled { + background-color: var(--gray-color); +} + +.status__dot { + width: 8px; + height: 8px; + min-width: 8px; + min-height: 8px; + border-radius: 100%; + margin-right: 0.4rem; +} + + +.item__figure { + display: flex; + align-items: flex-start; + flex-direction: row; + align-items: center; +} + +.item__figure img { + height: 50px; + margin-right: 1rem; +} diff --git a/src/static/styles/main.css b/src/static/styles/main.css new file mode 100644 index 0000000..59afa83 --- /dev/null +++ b/src/static/styles/main.css @@ -0,0 +1,424 @@ +:root { + --fg-color: #333; + --bg-color: #f9f9f9; + --gray-color: #9d9d9d; + --yellow-color: #f8a911; + --yellow-alt-color: #ffce6f; + + --default-border: 2px solid var(--gray-color); +} + +html { + font-size: 100%; +} + +body { + background: var(--bg-color); + font-family: 'Inter', sans-serif; + font-weight: 400; + max-width: 1024px; + padding: 1rem; + margin: 0 auto; + line-height: 1.75; + color: var(--fg-color); +} + +p { + margin-bottom: 1rem; +} + +a { + color: var(--fg-color); +} + +h1, h2, h3, h4, h5 { + margin: 0; + font-family: 'Eczar', serif; + font-weight: 700; + line-height: 1.3; +} + +h1 { + margin-top: 0; + font-size: 2.488rem; +} + +h2 { + font-size: 2.074rem; +} + +h3 { + font-size: 1.728rem; +} + +h4 { + font-size: 1.44rem; +} + +h5 { + font-size: 1.2rem; +} + +small, .text_small { + font-size: 0.833rem; +} + +label { + display: block; +} + + + + + + + +button, +input, +optgroup, +select, +textarea { + color: inherit; + font: inherit; + margin: 0; + max-width: 100%; +} + +input { + outline: 0; +} + + + +label { + text-align: left; + font-weight: 700; +} + +select { + text-align: left; + border: var(--default-border); + padding: 0.5em; + outline: 0; +} + +input[type=text], +input[type=email], +input[type=number], +input[type=password], +select[multiple=multiple], +textarea { + display: block; + text-align: left; + color: var(--fg-color); + border: var(--default-border); + padding: 0.5rem; + outline: 0; +} + + input:focus, + textarea:focus { + border-color: var(--yellow-color); + } + +select[multiple=multiple] { + height: 125px; +} + + +input[type=checkbox] { + width: 1em; + vertical-align: text-top; +} + +textarea { + width: 100%; + height: 6.25rem; + line-height: 1.45; +} + +::-webkit-input-placeholder { + font-style: oblique; +} +::-moz-input-placeholder { + font-style: oblique; +} +::-ms-input-placeholder { + font-style: oblique; +} + + +.action-button, +input[type=submit], +button { + font-family: inherit; + font-weight: bold; + font-size: 1rem; + color: var(--fg-color); + text-decoration: none; + text-transform: lowercase; + font-variant: small-caps; + background-color: var(--yellow-color); + padding: 0.25rem 1rem; + border-radius: 0.2rem; + border: none; + cursor: pointer; +} + +.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; +} + + + +figure { + margin: 0; + padding: 0; + box-sizing: border-box; +} +img { + box-sizing: border-box; +} + +.product__item { + display: grid; + grid-template-columns: repeat(2, 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; +} + +.product__form input[type=submit] { + grid-column: 2; + justify-self: end; +} + +.site__logo { + text-decoration: none; +} + +.site__header div, +.site__header nav { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +nav a { + text-transform: lowercase; + font-variant: small-caps; + 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; + color: var(--fg-color); + cursor: pointer; +} + + +.cart__icon { + width: 2rem; + height: 2rem; +} + +.cart__length { + font-size: 1.5rem; + font-weight: bold; + font-family: 'Eczar'; +} + +.cart__item { + display: grid; + grid-template-columns: 1fr 5fr; + gap: 1rem; + padding: 2rem 0; + border-bottom: 0.05rem solid var(--gray-color); +} + +.cart__total_price { + text-align: right; + font-size: 1.5rem; +} +.cart__total { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.item__figure img { + vertical-align: middle; +} + + +.product__list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8rem; +} + +.product__list-item { + text-decoration: none; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.product__list-item form { + grid-column: 1/3; +} + +.product__list-item figure { + margin: 0; + padding: 0; +} + + +.order__shipping { + margin-bottom: 3rem; +} + + +.order__total { + margin: 3rem 0; + text-align: right; +} + +.order__details { + /*margin: 3rem 0;*/ + +} + +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 { + 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; +} diff --git a/src/static/styles/storefront.css b/src/static/styles/storefront.css new file mode 100644 index 0000000..e69de29 diff --git a/src/storefront/__init__.py b/src/storefront/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/storefront/admin.py b/src/storefront/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/src/storefront/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/src/storefront/apps.py b/src/storefront/apps.py new file mode 100644 index 0000000..eb52d6d --- /dev/null +++ b/src/storefront/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StorefrontConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'storefront' diff --git a/src/storefront/cart.py b/src/storefront/cart.py new file mode 100644 index 0000000..07c7229 --- /dev/null +++ b/src/storefront/cart.py @@ -0,0 +1,139 @@ +import logging +from decimal import Decimal +from django.conf import settings +from core.models import Product, OrderLine + +logger = logging.getLogger(__name__) + +class Cart: + def __init__(self, request): + self.session = request.session + 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='', other='', customer_note='', update_quantity=False): + product_id = str(product.id) + if product_id not in self.cart: + self.cart[product_id] = { + 'quantity': 0, + 'roast': roast, + 'other': other, + 'customer_note': customer_note, + 'price': str(product.price) + } + if update_quantity: + self.cart[product_id]['quantity'] = quantity + else: + self.cart[product_id]['quantity'] += quantity + self.save() + + def save(self): + self.session[settings.CART_SESSION_ID] = self.cart + self.session.modified = True + logger.info(f'\nCart:\n{self.cart}\n\n') + + def remove(self, product): + product_id = str(product.id) + if product_id in self.cart: + del self.cart[product_id] + self.save() + + def __iter__(self): + product_ids = self.cart.keys() + products = Product.objects.filter(id__in=product_ids) + for product in products: + self.cart[str(product.id)]['product'] = product + + for item in self.cart.values(): + item['price'] = Decimal(item['price']) + item['total_price'] = item['price'] * item['quantity'] + yield item + + def __len__(self): + return sum(item['quantity'] for item in self.cart.values()) + + def get_total_price(self): + return sum(Decimal(item['price']) * item['quantity'] for item in self.cart.values()) + + def clear(self): + del self.session[settings.CART_SESSION_ID] + self.session.modified = True + + def get_bulk_list_and_body_data(self, order, shipping_address=None): + bulk_list = [] + + body_data = { + 'intent': 'CAPTURE', + 'purchase_units': [{ + 'amount': { + 'currency_code': 'USD', + 'value': f'{self.get_total_price()}', + 'breakdown': { + # Required when including the `items` array + 'item_total': { + 'currency_code': 'USD', + 'value': f'{self.get_total_price()}' + } + } + }, + 'items': [] + }] + } + + if shipping_address: + body_data['purchase_units'][0]['shipping'] = self.process_shipping_address(shipping_address) + + for item in self: + body_data['purchase_units'][0]['items'].append({ + # Shows within upper-right dropdown during payment approval + 'name': f'{item["product"]}', + # Item details will also be in the completed paypal.com transaction view + 'description': 'Coffee', + 'unit_amount': { + 'currency_code': 'USD', + 'value': f'{item["price"]}' + }, + 'quantity': f'{item["quantity"]}' + }) + + bulk_list.append( + OrderLine( + order=order, + product=item['product'], + customer_note=item['customer_note'], + unit_price=item['price'], + quantity=item['quantity'], + tax_rate=2, + ) + ) + + return bulk_list, body_data + + def process_shipping_address(self, address): + shipping = { + 'address': { + 'address_line_1': f'{address["street_address_1"]}', + 'address_line_2': f'{address["street_address_2"]}', + 'admin_area_2': f'{address["city"]}', + 'admin_area_1': f'{address["state"]}', + 'postal_code': f'{address["postal_code"]}', + 'country_code': 'US' + } + } + return shipping + + # @property + # def coupon(self): + # if self.coupon_id: + # return Coupon.objects.get(id=self.coupon_id) + # return None + + # def get_discount(self): + # if self.coupon: + # return (self.coupon.discount / Decimal('100')) * self.get_total_price() + # return Decimal('0') + + # def get_total_price_after_discount(self): + # return self.get_total_price() - self.get_discount() diff --git a/src/storefront/context_processors.py b/src/storefront/context_processors.py new file mode 100644 index 0000000..f29d3e2 --- /dev/null +++ b/src/storefront/context_processors.py @@ -0,0 +1,6 @@ +from .cart import Cart + +def cart(request): + return { + 'cart': Cart(request) + } diff --git a/src/storefront/forms.py b/src/storefront/forms.py new file mode 100644 index 0000000..53b1c64 --- /dev/null +++ b/src/storefront/forms.py @@ -0,0 +1,61 @@ +import logging +from django import forms +from django.core.mail import EmailMessage + +from core.models import Order +from accounts import STATE_CHOICES + +logger = logging.getLogger(__name__) + +class AddToCartForm(forms.Form): + WHOLE = 'WHOLE' + ESPRESSO = 'ESPRESSO' + CONE_DRIP = 'CONE_DRIP' + BASKET_DRIP = 'BASKET_DRIP' + FRENCH_PRESS = 'FRENCH_PRESS' + STOVETOP_ESPRESSO = 'STOVETOP_ESPRESSO' + AEROPRESS = 'AEROPRESS' + PERCOLATOR = 'PERCOLATOR' + OTHER = 'OTHER' + ROAST_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)') + ] + quantity = forms.IntegerField(min_value=1, initial=1) + roast = forms.ChoiceField(choices=ROAST_CHOICES) + other = forms.CharField(required=False) + update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput) + + +class AddressForm(forms.Form): + first_name = forms.CharField() + last_name = forms.CharField() + email = forms.EmailField() + street_address_1 = forms.CharField() + street_address_2 = forms.CharField(required=False) + city = forms.CharField() + state = forms.ChoiceField( + choices=STATE_CHOICES + ) + postal_code = forms.CharField() + +class OrderCreateForm(forms.ModelForm): + email = forms.CharField(widget=forms.HiddenInput()) + first_name = forms.CharField(widget=forms.HiddenInput()) + last_name = forms.CharField(widget=forms.HiddenInput()) + + class Meta: + model = Order + fields = ( + 'total_net_amount', + ) + widgets = { + 'total_net_amount': forms.HiddenInput() + } diff --git a/src/storefront/migrations/__init__.py b/src/storefront/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/storefront/models.py b/src/storefront/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/src/storefront/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/src/storefront/signals.py b/src/storefront/signals.py new file mode 100644 index 0000000..dd56ead --- /dev/null +++ b/src/storefront/signals.py @@ -0,0 +1,31 @@ +# import logging +# from io import BytesIO + +# from django.db.models.signals import post_save +# from django.dispatch import receiver + +# from core import TransactionStatus +# from core.models import Order, Transaction +# from .tasks import send_order_confirmation_email + +# logger = logging.getLogger(__name__) + +# @receiver(post_save, sender=Order, dispatch_uid="order_created") +# def order_created(sender, instance, created, **kwargs): +# if created: +# logger.info("Order was created") +# Transaction.objects.create(order=instance) + + +# @receiver(post_save, sender=Transaction, dispatch_uid="transaction_created") +# def transaction_created(sender, instance, created, **kwargs): +# if created: +# logger.info("Transaction was created") + +# elif instance.status == TransactionStatus.COMPLETED: +# order = { +# 'order_id': instance.order.pk, +# 'email': instance.order.customer.email, +# 'full_name': instance.order.customer.get_full_name() +# } +# send_order_confirmation_email.delay(order) diff --git a/src/storefront/tasks.py b/src/storefront/tasks.py new file mode 100644 index 0000000..e02e9db --- /dev/null +++ b/src/storefront/tasks.py @@ -0,0 +1,26 @@ +# 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 core.models import Order + +# logger = get_task_logger(__name__) + + +# CONFIRM_ORDER_TEMPLATE = 'storefront/order_confirmation' +# ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' +# ORDER_REFUND_TEMPLATE = 'storefront/order_refund' + +# @shared_task(name='send_order_confirmation_email') +# def send_order_confirmation_email(order): +# send_templated_mail( +# template_name=CONFIRM_ORDER_TEMPLATE, +# from_email=settings.DEFAULT_FROM_EMAIL, +# recipient_list=[order['email']], +# context=order +# ) + +# logger.info(f"Order confirmation email sent to {order['email']}") diff --git a/src/storefront/templates/storefront/about.html b/src/storefront/templates/storefront/about.html new file mode 100644 index 0000000..6a01753 --- /dev/null +++ b/src/storefront/templates/storefront/about.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+

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.

+

You’ve probably been around coffee for years, perhaps starting with Specialty Coffees in the 1980’s, and know your way around fairly well. How and where coffee is grown and harvested, how it’s roasted, how to brew it. You value fair, guaranteed wages for growers and sustainable stewardship of the land where it’s grown.

+

No matter how much you drink, if you’re like us, great coffee is an important perk in your life. You are not alone in this… coffee is the one of the world’s most heavily traded commodities. In addition to the impact it has as a crop on the economies of producing countries, the cafe and coffee house industry touches millions of lives everyday, worldwide, as well.

+

We love the place coffee takes in conversation, culture and commerce. We seek out the world’s rarest coffees for taste and consistency. We roast and blend coffees at Port Townsend Coffee Roasting Co. with the hope that you will enjoy every cup.

+
+
+

Style of roast

+

We roast in a European style, specifically similar to that of central Italy. The blending and roasting process determines the flavor and body of the coffee equally as much as the kinds of beans used. Italians are noted for their blending skills.

+

We blend and roast to suit our own personal tastes, creating a smooth, less acidic, sweeter flavor. Our coffee has a perceivably heavy feeling in the mouth. Our roasts take longer and require attention. Controlling air flow around the beans in the roasting drum is crucial in developing the flavor and body of the coffees we roast. At times in the roasting, no heat at all is applied to the beans. Done correctly, this can smooth out an otherwise acidic or snappy roast.

+

For those who are interested in comparing roasting styles: This is in contrast to the style of many roasters here in the Pacific Northwest. They tend towards lighter, faster roasts with different air flows around the beans, which produces a more acidic, lighter-bodied coffee. This style has many fans. It produces coffees in which the nuances of singular flavors can be discerned, as nuances of flavor can be picked out in wines. However, we prefer smoother, heavier and more rounded flavors and strive to capture them in my roasting.

+
+
+

Your Coffee Primer: what gives Port Townsend Coffee its qualities?

+

We roast Port Townsend Coffee in small batches (under 30 lbs), engendering uniformity in the roast level. Coffee roasted in big batches may be unevenly roasted.

+

Port Townsend Coffee is air cooled, as opposed to water cooled. Many of the larger roasters must spray water on the coffee as it comes out of the roasting chamber, to cool it down. This affects the quality of the coffee and its ability to remain fresh if the water is not properly evaporated by the heat from the coffee.

+

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
+

At Port Townsend Coffee, we roast a batch and within 15 minutes, package it in one-way valve bags so the coffee can “de-gas.” The valve allows the gas to escape, but prevents oxygen from coming into contact with the coffee (oxygen causes coffee to go stale). Many roasters allow their coffee to ‘de-gas’ by holding it in large bins for several days before packaging it. This procedure can cause the taste to go flat.

+
The Diedrich Roaster
+

We have used 5 different Diedrich Coffee Roasters since 1985. Compared to other machines, they produce an evenly developed roast. The infrared burners are much gentler than the direct flame burners on most other roasters, and the air flow control give a greater range of what can be done for the beans.

+
Freshness
+

Fresh coffee is better! We use one-way valve bags to the coffee is flushed with protective carbon dioxide and protected from the deteriorating effects of oxygen.

+

The best coffee is fresh from the roaster; however, the coffee in our valve bags keeps its flavor intact for a month without a perceivable taste difference to most people.

+

We recommend you buy enough coffee for a one-month supply. Keep it in the valve bags, in a cool place—such as a lower kitchen cabinet. Once the bag is opened, keep air out by rolling down the top of the bag and secure with a rubber band. Remember, air (oxidation) will cause your beans to go stale!

+

We also recommend you buy whole beans and get yourself a coffee grinder. Grinding exposes much more surface area to oxygen.

+

Last, never put coffee beans in the refrigerator or freezer, as this speeds up oxidation.

+
+
+
+{% endblock %} diff --git a/src/storefront/templates/storefront/cart_detail.html b/src/storefront/templates/storefront/cart_detail.html new file mode 100644 index 0000000..23c786b --- /dev/null +++ b/src/storefront/templates/storefront/cart_detail.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{user}}

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

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

+

Grind options: {{item.customer_note}}

+

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

+ Remove from cart +

+
+ {% endwith %} +
+ {% empty %} +

No items in cart yet.

+ {% endfor %} +
+
+

Cart total: ${{cart.get_total_price}}

+

+ Continue Shopping or Proceed to Checkout +

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

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 new file mode 100644 index 0000000..f529cf6 --- /dev/null +++ b/src/storefront/templates/storefront/checkout_shipping.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+

Checkout

+
+
+

Shipping Method

+
+ {{form.as_p}} + +
+
+
+
+{% endblock %} diff --git a/src/storefront/templates/storefront/customer_detail.html b/src/storefront/templates/storefront/customer_detail.html new file mode 100644 index 0000000..289a626 --- /dev/null +++ b/src/storefront/templates/storefront/customer_detail.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+
+

{{customer.get_full_name}}

+ Edit profile +
+
+
+

Info

+
+
+ Email address
+ {{customer.email}}
+ Manage +
+
+ Default shipping address
+ {% with shipping_address=customer.default_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 + {% endwith %} +
+
+ Other addresses
+ {% for address in customer.addresses.all %} +

+

+ {{address.first_name}} + {{address.last_name}}
+ {{address.street_address_1}}
+ {% if address.street_address_2 %} + {{address.street_address_2}}
+ {% endif %} + {{address.city}}, {{address.state}}, {{address.postal_code}} +
+ 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 %} +
+{% endblock content %} diff --git a/src/storefront/templates/storefront/customer_form.html b/src/storefront/templates/storefront/customer_form.html new file mode 100644 index 0000000..4ef2901 --- /dev/null +++ b/src/storefront/templates/storefront/customer_form.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block content %} +
+

Update your profile

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

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/storefront/templates/storefront/order_form.html b/src/storefront/templates/storefront/order_form.html new file mode 100644 index 0000000..d648d57 --- /dev/null +++ b/src/storefront/templates/storefront/order_form.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% 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 %} +
+ {% endfor %} +
+
+
+ {% csrf_token %} + {{form.as_p}} + {# #} +
+

Total: ${{cart.get_total_price}}

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

Payment declined

+

There was a problem collecting payment.

+
+{% endblock %} \ No newline at end of file diff --git a/src/storefront/templates/storefront/payment_done.html b/src/storefront/templates/storefront/payment_done.html new file mode 100644 index 0000000..76491b3 --- /dev/null +++ b/src/storefront/templates/storefront/payment_done.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +
+

Payment was successful

+

Thank you for your order!

+
+{% endblock %} \ No newline at end of file diff --git a/src/storefront/templates/storefront/product_detail.html b/src/storefront/templates/storefront/product_detail.html new file mode 100644 index 0000000..2f8b974 --- /dev/null +++ b/src/storefront/templates/storefront/product_detail.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block content %} + +
+
+ {{product.productphoto_set.first.image}} +
+
+

{{product.name}}

+

{{product.description}}

+

${{product.price}}

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+ +{% endblock %} diff --git a/src/storefront/templates/storefront/product_list.html b/src/storefront/templates/storefront/product_list.html new file mode 100644 index 0000000..69a11dc --- /dev/null +++ b/src/storefront/templates/storefront/product_list.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} + +{% block content %} + +{% endblock %} + +{% block footer %} +
+{% endblock footer %} diff --git a/src/storefront/tests.py b/src/storefront/tests.py new file mode 100644 index 0000000..4fb9587 --- /dev/null +++ b/src/storefront/tests.py @@ -0,0 +1,61 @@ +import logging +from django.test import TestCase, Client, RequestFactory +from django.urls import reverse +from django.conf import settings + +from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest +from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment + +from accounts.models import User, Address +from core.models import Product, Order + +from .views import OrderCreateView +from .cart import Cart + +logger = logging.getLogger(__name__) + +class CartTest(TestCase): + def setUp(self): + self.client = Client() + + self.customer = User.objects.create_user( + username='Peter Templer', email='peter@testing.com', password='peterspassword321' + ) + self.product = Product.objects.create( + name='Dante\'s Tornado', + description='Coffee', + sku='23987', + price=13.4, + weight=10, + visible_in_listings=True + ) + self.order = Order.objects.create( + customer=self.customer, + total_net_amount=13.4 + ) + + self.client.force_login(self.customer) + + def test_post_checkout_form(self): + url = reverse('storefront:order-create') + response = self.client.get(url) + self.assertTemplateUsed(response, 'storefront/order_form.html') + + request = response.wsgi_request + cart = Cart(request) + cart.add( + product=self.product, + quantity=1, + update_quantity=False + ) + logger.info(f'Body data:\n{body_data}\n') + + + params = { + 'email': 'nathanchapman@hey.com', + 'first_name': 'Nathan', + 'last_name': 'Chapman', + 'total_net_amount': 26.80 + } + + response = self.client.post(url, params) diff --git a/src/storefront/urls.py b/src/storefront/urls.py new file mode 100644 index 0000000..365cacc --- /dev/null +++ b/src/storefront/urls.py @@ -0,0 +1,28 @@ +from django.urls import path, include +from . import views + +urlpatterns = [ + path('about/', views.AboutView.as_view(), name='about'), + + path('', views.ProductListView.as_view(), name='product-list'), + path('products//', include([ + path('', views.ProductDetailView.as_view(), name='product-detail'), + ])), + + path('cart/', views.CartView.as_view(), name='cart-detail'), + path('cart//add/', views.CartAddProductView.as_view(), name='cart-add'), + path('cart//remove/', views.cart_remove_product_view, name='cart-remove'), + + path('paypal/order//capture/', views.paypal_order_transaction_capture, name='paypal-capture'), + + path('checkout/address/', views.CheckoutAddressView.as_view(), name='checkout-address'), + path('checkout/', views.OrderCreateView.as_view(), name='order-create'), + path('done/', views.PaymentDoneView.as_view(), name='payment-done'), + path('canceled/', views.PaymentCanceledView.as_view(), name='payment-canceled'), + + path('customers//', include([ + path('', views.CustomerDetailView.as_view(), name='customer-detail'), + path('update/', views.CustomerUpdateView.as_view(), name='customer-update'), + # path('delete/', views.CustomerDeleteView.as_view(), name='customer-delete'), + ])), +] diff --git a/src/storefront/views.py b/src/storefront/views.py new file mode 100644 index 0000000..a65544c --- /dev/null +++ b/src/storefront/views.py @@ -0,0 +1,232 @@ +import logging +import requests +from django.conf import settings +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.http import JsonResponse +from django.views.generic.base import RedirectView, TemplateView +from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView, FormMixin +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 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 core.forms import ShippingMethodForm + +from .forms import AddToCartForm, OrderCreateForm, AddressForm +from .cart import Cart + +logger = logging.getLogger(__name__) + +class CartView(TemplateView): + template_name = 'storefront/cart_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + cart = Cart(self.request) + for item in cart: + item['update_quantity_form'] = AddToCartForm( + initial={ + 'quantity': item['quantity'], + 'update': True + } + ) + context['cart'] = cart + return context + +class CartAddProductView(SingleObjectMixin, FormView): + model = Product + form_class = AddToCartForm + + 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(), + roast=form.cleaned_data['roast'], + other=form.cleaned_data['other'], + quantity=form.cleaned_data['quantity'], + update_quantity=form.cleaned_data['update'] + ) + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + return super().form_valid(form) + + +def cart_remove_product_view(request, pk): + cart = Cart(request) + product = get_object_or_404(Product, id=pk) + cart.remove(product) + return redirect('storefront:cart-detail') + + +class ProductListView(FormMixin, ListView): + model = Product + template_name = 'storefront/product_list.html' + form_class = AddToCartForm + + queryset = Product.objects.filter( + visible_in_listings=True + ) + + +class ProductDetailView(FormMixin, DetailView): + model = Product + template_name = 'storefront/product_detail.html' + form_class = AddToCartForm + +def paypal_order_transaction_capture(request, transaction_id): + if request.method =="POST": + capture_order = OrdersCaptureRequest(transaction_id) + environment = SandboxEnvironment(client_id=settings.PAYPAL_CLIENT_ID, client_secret=settings.PAYPAL_SECRET_ID) + client = PayPalHttpClient(environment) + + response = client.execute(capture_order) + data = response.result.__dict__['_dict'] + data['redirect_urls'] = { + 'return_url': request.build_absolute_uri(reverse_lazy('storefront:payment-done')), + 'cancel_url': request.build_absolute_uri(reverse_lazy('storefront:payment-canceled')) + } + transaction = Transaction.objects.get(order__pk=request.session.get('order_id')) + transaction.paypal_id = data['purchase_units'][0]['payments']['captures'][0]['id'] + transaction.status = data['status'] + transaction.save() + logger.debug(f'\nPayPal Response data: {data}\n') + + return JsonResponse(data) + else: + return JsonResponse({'details': 'invalid request'}) + + +class CheckoutAddressView(FormView): + template_name = 'storefront/checkout_address.html' + form_class = AddressForm + success_url = reverse_lazy('storefront:order-create') + + def get_initial(self): + user = self.request.user + initial = None + if user.is_authenticated and user.default_shipping_address: + address = user.default_shipping_address + initial = { + 'first_name': address.first_name, + 'last_name': address.last_name, + 'email': user.email, + 'street_address_1': address.street_address_1, + 'street_address_2': address.street_address_2, + 'city': address.city, + 'state': address.state, + 'postal_code': address.postal_code + } + return initial + + def form_valid(self, form): + # save address data to session + self.request.session['shipping_address'] = form.cleaned_data + return super().form_valid(form) + +class OrderCreateView(CreateView): + model = Order + template_name = 'storefront/order_form.html' + form_class = OrderCreateForm + success_url = reverse_lazy('storefront:payment-done') + + def get_initial(self): + cart = Cart(self.request) + initial = { + 'total_net_amount': cart.get_total_price() + } + + if self.request.user.is_authenticated: + user_info = { + 'email': self.request.user.email, + 'first_name': self.request.user.first_name, + 'last_name': self.request.user.last_name, + } + initial |= user_info + else: + a = self.request.session.get('shipping_address') + user_info = { + 'email': a['email'], + 'first_name': a['first_name'], + 'last_name': a['last_name'] + } + initial |= user_info + + return initial + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['shipping_address'] = self.request.session.get('shipping_address') + return context + + def form_valid(self, form): + shipping_address = self.request.session.get('shipping_address') + form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address) + self.object = form.save() + + # Cart data setup + cart = Cart(self.request) + bulk_list, body_data = cart.get_bulk_list_and_body_data(self.object, shipping_address) + logger.debug(f'\nBody data: {body_data}\n') + + # Bulk create OrderLine objects from cart items + objs = OrderLine.objects.bulk_create(bulk_list) + + # PayPal setup + environment = SandboxEnvironment(client_id=settings.PAYPAL_CLIENT_ID, client_secret=settings.PAYPAL_SECRET_ID) + client = PayPalHttpClient(environment) + create_order = OrdersCreateRequest() + create_order.request_body(body_data) + + response = client.execute(create_order) + data = response.result.__dict__['_dict'] + + cart.clear() + self.request.session['order_id'] = self.object.id + + return JsonResponse(data) + + +class PaymentDoneView(TemplateView): + template_name = 'storefront/payment_done.html' + +class PaymentCanceledView(TemplateView): + template_name = 'storefront/payment_canceled.html' + + +class CustomerDetailView(DetailView): + model = User + template_name = 'storefront/customer_detail.html' + context_object_name = 'customer' + +class CustomerUpdateView(UpdateView): + model = User + template_name = 'storefront/customer_form.html' + context_object_name = 'customer' + fields = ( + 'first_name', + 'last_name', + 'email', + 'default_shipping_address' + ) + + def get_success_url(self): + return reverse('storefront:customer-detail', kwargs={'pk': self.object.pk}) + +class AboutView(TemplateView): + template_name = 'storefront/about.html' diff --git a/src/templates/account/email.html b/src/templates/account/email.html new file mode 100644 index 0000000..d54b282 --- /dev/null +++ b/src/templates/account/email.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block head_title %}{% trans "E-mail Addresses" %}{% endblock %} + +{% block content %} +
+
+

{% trans "E-mail Addresses" %}

+
+
+ {% if user.emailaddress_set.all %} +

{% trans 'The following e-mail addresses are associated with your account:' %}

+ + {% else %} +

{% trans 'Warning:'%} {% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}

+ {% endif %} +
+
+ {% if can_add_email %} +

{% trans "Add E-mail Address" %}

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ {% endif %} +
+ {% endblock %} +
+ + +{% block extra_body %} + +{% endblock %} diff --git a/src/templates/account/logged_out.html b/src/templates/account/logged_out.html new file mode 100755 index 0000000..0b464dc --- /dev/null +++ b/src/templates/account/logged_out.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

You have been logged out. Log in

+
+
+{% endblock %} diff --git a/src/templates/account/login.html b/src/templates/account/login.html new file mode 100755 index 0000000..d71ec39 --- /dev/null +++ b/src/templates/account/login.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Log in

+ {% if form.errors %} +

Your username and password didn't match. Please try again.

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

+ Forgot your password? +

+

+ + +

+
+
+{% endblock %} diff --git a/src/templates/account/logout.html b/src/templates/account/logout.html new file mode 100644 index 0000000..6d8a5d9 --- /dev/null +++ b/src/templates/account/logout.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Log Out

+

Are you sure you want to log out?

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

+ + +

+
+
+{% endblock %} diff --git a/src/templates/account/password_change_done.html b/src/templates/account/password_change_done.html new file mode 100755 index 0000000..fb611b7 --- /dev/null +++ b/src/templates/account/password_change_done.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

+ Password has been changed. + Log in +

+
+
+{% endblock %} diff --git a/src/templates/account/password_change_form.html b/src/templates/account/password_change_form.html new file mode 100755 index 0000000..cad7d69 --- /dev/null +++ b/src/templates/account/password_change_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Change password

+
+ {% csrf_token %} + {{ form.as_p }} + + or + Cancel +
+
+{% endblock %} diff --git a/src/templates/account/password_reset_complete.html b/src/templates/account/password_reset_complete.html new file mode 100755 index 0000000..ad0c905 --- /dev/null +++ b/src/templates/account/password_reset_complete.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Password was reset successfully.

+
+{% endblock %} diff --git a/src/templates/account/password_reset_confirm.html b/src/templates/account/password_reset_confirm.html new file mode 100755 index 0000000..1aaf4a8 --- /dev/null +++ b/src/templates/account/password_reset_confirm.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} + +{% block content %} + +{% if validlink %} +
+

Reset password

+

Enter a new password below.

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

Password reset failed

+
+ +{% endif %} + +{% endblock %} diff --git a/src/templates/account/password_reset_done.html b/src/templates/account/password_reset_done.html new file mode 100755 index 0000000..8f2b394 --- /dev/null +++ b/src/templates/account/password_reset_done.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} + +{% block content %} +
+

An email with password reset instructions has been sent.

+
+{% endblock %} diff --git a/src/templates/account/password_reset_email.html b/src/templates/account/password_reset_email.html new file mode 100755 index 0000000..8c37094 --- /dev/null +++ b/src/templates/account/password_reset_email.html @@ -0,0 +1,10 @@ +{% load i18n %}{% autoescape off %} +{% blocktranslate %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktranslate %} + +{% translate "Please go to the following page and choose a new password:" %} +{% block reset_link %} +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} +{% endblock %} +{% translate 'Your username, in case you’ve forgotten:' %} {{ user.get_username }} + +{% endautoescape %} diff --git a/src/templates/account/password_reset_form.html b/src/templates/account/password_reset_form.html new file mode 100755 index 0000000..1a21366 --- /dev/null +++ b/src/templates/account/password_reset_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} {% block content %} +
+

Reset your password

+

Enter your email address below and we'll send you instructions on how to reset your password.

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

+ +

+
+
+{% endblock %} diff --git a/src/templates/account/signup.html b/src/templates/account/signup.html new file mode 100644 index 0000000..503f03f --- /dev/null +++ b/src/templates/account/signup.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Sign up

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

+ + +

+
+
+{% endblock %} diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..4b13bd1 --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,87 @@ +{% load static %} +{% load compress %} +{% load account %} + + + + + + + {% block head_title %}{% endblock %} Port Townsend Coffee + + + + + + {% compress css %} + + {% endcompress %} + + + + {% block head %} + {% endblock %} + + + + + +
+{% block content %} +{% endblock content %} +
+
+
+
+ Keep calm and drink coffee +
+
+

Problem with your order?
Have a question?

+

Please contact us, we’re happy to help you over the phone at (360) 385-5856 between 8:00 am and 10:00 pm Pacific Time.

+ {% block footer %} + {% endblock footer %} +
+
+ +
+ + diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html new file mode 100644 index 0000000..5cbc9af --- /dev/null +++ b/src/templates/dashboard.html @@ -0,0 +1,74 @@ +{% load static %} +{% load compress %} + + + + + + + Dashboard | Port Townsend Coffee + + + + + + {% compress css %} + + {% endcompress %} + + + + {% block head %} + {% endblock %} + + +
+ +
+ {% block content %} + {% endblock content %} +
+
+
+ +
+ + diff --git a/src/templates/templated_email/accounts/account_created.email b/src/templates/templated_email/accounts/account_created.email new file mode 100644 index 0000000..25c4587 --- /dev/null +++ b/src/templates/templated_email/accounts/account_created.email @@ -0,0 +1,22 @@ +{% block subject %}PT Coffee: Account created{% endblock %} +{% block plain %} + Hi {{full_name}}, your account has been created. + + Username: {{email}} + Password: {{password}} + + Thanks, + Port Townsend Coffee +{% endblock %} + +{% block html %} +

Your account has been created.

+ +

+ Username: {{email}}
+ Password: {{password}} +

+ +

Thanks,
+ Port Townsend Coffee

+{% endblock %} diff --git a/src/templates/templated_email/compiled/order_confimation.html b/src/templates/templated_email/compiled/order_confimation.html new file mode 100644 index 0000000..fb30a47 --- /dev/null +++ b/src/templates/templated_email/compiled/order_confimation.html @@ -0,0 +1,556 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {% load i18n %} + {% load i18n_address_tags %}{% load static %} + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ +
{% trans "Hi!" context "Standard e-mail greeting" %}
+ +
+ +
{% if order_details_url %} + {% blocktrans trimmed context "Order confirmation e-mail text with payment details" %} + Thank you for your order. Below is the list of ordered products. To see your payment details please visit: {{ order_details_url }} + {% endblocktrans %} + {% else %} + {% blocktrans trimmed context "Order confirmation e-mail text" %} + Thank you for your order. Below is the list of ordered products. + {% endblocktrans %} + {% endif %}
+ +
+ +
+ + +
+ +
+ + + + + {% load display_translated_order_line_name from order_lines %} + {% load price from taxed_prices %} + {% load voucher %} + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + {% if order.discount_amount %} + + + + + {% endif %} + + + + + + + + + + + + + + + {% for line in order %} + + + + + + + {% endfor %} + +
+ {% trans "Subtotal" context "E-mail order lines summary table" %} + + {% price order.get_subtotal display_gross=order.display_gross_prices html=False %} +
+ {% trans "Shipping" context "E-mail order lines summary table" %} + + {% price order.shipping_price display_gross=order.display_gross_prices html=False %} +
+ {% if order.display_gross_prices %} + {% trans "Taxes (included)" context "E-mail order lines summary table" %} + {% else %} + {% trans "Taxes" context "E-mail order lines summary table" %} + {% endif %} + + {% price order.total.tax html=False %} +
+ {% trans "Discount" context "E-mail order lines summary table" %} + + {% discount_as_negative order.discount html=True %} +
+ {% trans "Total" context "E-mail order lines summary table" %} + + {% price order.total display_gross=order.display_gross_prices html=False %} +
{% trans "Item" context "Ordered item name" %}{% trans "Quantity" context "Quantity ordered of a product" %}{% trans "Per unit" context "Unit price of a product" %}{% trans "Subtotal" context "Ordered item subtotal (unit price * quantity)" %}
{% display_translated_order_line_name line %}{{ line.quantity }} + {% price line.unit_price display_gross=order.display_gross_prices html=False %} + {% price line.get_total display_gross=order.display_gross_prices html=False %}
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + +
{% trans "Billing address" context "Order confirmation e-mail billing address" %}{% trans "Shipping address" context "Order confirmation e-mail shipping address" %}
+ {% if order.billing_address %} + {% format_address order.billing_address %} + {% else %} + {% trans "No billing address" context "Order confirmation e-mail text" %} + {% endif %} + + {% if order.shipping_address %} + {% format_address order.shipping_address %} + {% else %} + {% trans "No shipping required" context "Order confirmation e-mail text" %} + {% endif %} +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ +
{% blocktrans trimmed context "Base email text" %} + This is an automatically generated e-mail, please do not reply. + {% endblocktrans %}
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ +
{% blocktrans trimmed context "Base email footer" %} + Sincerely, {{ site_name }} + {% endblocktrans %}
+ +
+ +
+ + +
+ +
+ + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/src/templates/templated_email/storefront/order_confirmation.email b/src/templates/templated_email/storefront/order_confirmation.email new file mode 100644 index 0000000..7a3c75d --- /dev/null +++ b/src/templates/templated_email/storefront/order_confirmation.email @@ -0,0 +1,18 @@ +{% block subject %}PT Coffee: Confirmation for order #{{order_id}}{% endblock %} +{% block plain %} + Thank you for your order! + + Hi {{full_name}}, we roast our coffee as orders come in. We'll send you a seperate email when your order ships. To view or change your order... + + Thanks, + Port Townsend Coffee +{% endblock %} + +{% block html %} +

Thank you for your order!

+ +

Hi {{full_name}}, we roast our coffee as orders come in. We'll send you a seperate email when your order ships. To view or change your order...

+ +

Thanks,
+ Port Townsend Coffee

+{% endblock %}