207
.gitignore
vendored
Normal file
207
.gitignore
vendored
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
|
||||||
|
# Created by https://www.gitignore.io/api/vim,python,django,virtualenv
|
||||||
|
# Edit at https://www.gitignore.io/?templates=vim,python,django,virtualenv
|
||||||
|
|
||||||
|
### Django ###
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
media
|
||||||
|
|
||||||
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
|
# in your Git repository. Update and uncomment the following line accordingly.
|
||||||
|
# <django-project-name>/staticfiles/
|
||||||
|
|
||||||
|
### Django.Python Stack ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# 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
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# Mr Developer
|
||||||
|
.mr.developer.cfg
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
|
||||||
|
# Mr Developer
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
|
||||||
|
### Vim ###
|
||||||
|
# Swap
|
||||||
|
[._]*.s[a-v][a-z]
|
||||||
|
[._]*.sw[a-p]
|
||||||
|
[._]s[a-rt-v][a-z]
|
||||||
|
[._]ss[a-gi-z]
|
||||||
|
[._]sw[a-p]
|
||||||
|
|
||||||
|
# Session
|
||||||
|
Session.vim
|
||||||
|
Sessionx.vim
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
.netrwhist
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Auto-generated tag files
|
||||||
|
tags
|
||||||
|
|
||||||
|
# Persistent undo
|
||||||
|
[._]*.un~
|
||||||
|
|
||||||
|
# Coc configuration directory
|
||||||
|
.vim
|
||||||
|
|
||||||
|
### VirtualEnv ###
|
||||||
|
# Virtualenv
|
||||||
|
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
|
||||||
|
pyvenv.cfg
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
pip-selfcheck.json
|
||||||
|
|
||||||
|
# End of https://www.gitignore.io/api/vim,python,django,virtualenv
|
||||||
20
Pipfile
Normal file
20
Pipfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[[source]]
|
||||||
|
name = "pypi"
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
black = "==19.10b0"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
django = "==2.2"
|
||||||
|
django-bootstrap4 = "*"
|
||||||
|
django-invitations = "*"
|
||||||
|
django-hashid-field = "*"
|
||||||
|
frozendict = "*"
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.8"
|
||||||
|
|
||||||
|
[pipenv]
|
||||||
|
allow_prereleases = true
|
||||||
189
Pipfile.lock
generated
Normal file
189
Pipfile.lock
generated
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"hash": {
|
||||||
|
"sha256": "36bd6ea9711c247710904103a81d2a2abae1000e988ebee11d5eaa22697d5cdf"
|
||||||
|
},
|
||||||
|
"pipfile-spec": 6,
|
||||||
|
"requires": {
|
||||||
|
"python_version": "3.8"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "pypi",
|
||||||
|
"url": "https://pypi.org/simple",
|
||||||
|
"verify_ssl": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"beautifulsoup4": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a",
|
||||||
|
"sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887",
|
||||||
|
"sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae"
|
||||||
|
],
|
||||||
|
"version": "==4.8.2"
|
||||||
|
},
|
||||||
|
"django": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7c3543e4fb070d14e10926189a7fcf42ba919263b7473dceaefce34d54e8a119",
|
||||||
|
"sha256:a2814bffd1f007805b19194eb0b9a331933b82bd5da1c3ba3d7b7ba16e06dc4b"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.2"
|
||||||
|
},
|
||||||
|
"django-bootstrap4": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0fcd84f8414a58b43df0b331c00c8b2f1786ae28f75f419b4d33b06fca43e0d1",
|
||||||
|
"sha256:39f97cbce85eb66f6d76be2029bae171bd3863d0c6932b1c2dae7f299c569b90"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==1.1.1"
|
||||||
|
},
|
||||||
|
"django-hashid-field": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:328e83f13ab0eedd4ed8a384bde8b7a4ce18ee8ee1e1d149247ba7611fc7addb",
|
||||||
|
"sha256:cff0805a4c4243d1c30d180e70efbe08395bddce4177d4c968c1f9bf0542a5a5"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==3.0.0"
|
||||||
|
},
|
||||||
|
"django-invitations": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:fe0a4c822bf695f7dac9f828c95f1dcee42dc590ed2943bd4d6ec8a1e03ab08e"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==1.9.2"
|
||||||
|
},
|
||||||
|
"frozendict": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:774179f22db2ef8a106e9c38d4d1f8503864603db08de2e33be5b778230f6e45"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==1.2"
|
||||||
|
},
|
||||||
|
"hashids": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:6539b892a426e75747a9c0ad69409e9566f9c21b79310fc3424b5b6726f28da6"
|
||||||
|
],
|
||||||
|
"version": "==1.2.0"
|
||||||
|
},
|
||||||
|
"pytz": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
||||||
|
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
||||||
|
],
|
||||||
|
"version": "==2019.3"
|
||||||
|
},
|
||||||
|
"soupsieve": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5",
|
||||||
|
"sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda"
|
||||||
|
],
|
||||||
|
"version": "==1.9.5"
|
||||||
|
},
|
||||||
|
"sqlparse": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177",
|
||||||
|
"sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873"
|
||||||
|
],
|
||||||
|
"version": "==0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"develop": {
|
||||||
|
"appdirs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
|
||||||
|
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
|
||||||
|
],
|
||||||
|
"version": "==1.4.3"
|
||||||
|
},
|
||||||
|
"attrs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
||||||
|
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
||||||
|
],
|
||||||
|
"version": "==19.3.0"
|
||||||
|
},
|
||||||
|
"black": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
|
||||||
|
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==19.10b0"
|
||||||
|
},
|
||||||
|
"click": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
||||||
|
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
||||||
|
],
|
||||||
|
"version": "==7.0"
|
||||||
|
},
|
||||||
|
"pathspec": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424",
|
||||||
|
"sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"
|
||||||
|
],
|
||||||
|
"version": "==0.7.0"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:07b39bf943d3d2fe63d46281d8504f8df0ff3fe4c57e13d1656737950e53e525",
|
||||||
|
"sha256:0932941cdfb3afcbc26cc3bcf7c3f3d73d5a9b9c56955d432dbf8bbc147d4c5b",
|
||||||
|
"sha256:0e182d2f097ea8549a249040922fa2b92ae28be4be4895933e369a525ba36576",
|
||||||
|
"sha256:10671601ee06cf4dc1bc0b4805309040bb34c9af423c12c379c83d7895622bb5",
|
||||||
|
"sha256:23e2c2c0ff50f44877f64780b815b8fd2e003cda9ce817a7fd00dea5600c84a0",
|
||||||
|
"sha256:26ff99c980f53b3191d8931b199b29d6787c059f2e029b2b0c694343b1708c35",
|
||||||
|
"sha256:27429b8d74ba683484a06b260b7bb00f312e7c757792628ea251afdbf1434003",
|
||||||
|
"sha256:3e77409b678b21a056415da3a56abfd7c3ad03da71f3051bbcdb68cf44d3c34d",
|
||||||
|
"sha256:4e8f02d3d72ca94efc8396f8036c0d3bcc812aefc28ec70f35bb888c74a25161",
|
||||||
|
"sha256:4eae742636aec40cf7ab98171ab9400393360b97e8f9da67b1867a9ee0889b26",
|
||||||
|
"sha256:6a6ae17bf8f2d82d1e8858a47757ce389b880083c4ff2498dba17c56e6c103b9",
|
||||||
|
"sha256:6a6ba91b94427cd49cd27764679024b14a96874e0dc638ae6bdd4b1a3ce97be1",
|
||||||
|
"sha256:7bcd322935377abcc79bfe5b63c44abd0b29387f267791d566bbb566edfdd146",
|
||||||
|
"sha256:98b8ed7bb2155e2cbb8b76f627b2fd12cf4b22ab6e14873e8641f266e0fb6d8f",
|
||||||
|
"sha256:bd25bb7980917e4e70ccccd7e3b5740614f1c408a642c245019cff9d7d1b6149",
|
||||||
|
"sha256:d0f424328f9822b0323b3b6f2e4b9c90960b24743d220763c7f07071e0778351",
|
||||||
|
"sha256:d58e4606da2a41659c84baeb3cfa2e4c87a74cec89a1e7c56bee4b956f9d7461",
|
||||||
|
"sha256:e3cd21cc2840ca67de0bbe4071f79f031c81418deb544ceda93ad75ca1ee9f7b",
|
||||||
|
"sha256:e6c02171d62ed6972ca8631f6f34fa3281d51db8b326ee397b9c83093a6b7242",
|
||||||
|
"sha256:e7c7661f7276507bce416eaae22040fd91ca471b5b33c13f8ff21137ed6f248c",
|
||||||
|
"sha256:ecc6de77df3ef68fee966bb8cb4e067e84d4d1f397d0ef6fce46913663540d77"
|
||||||
|
],
|
||||||
|
"version": "==2020.1.8"
|
||||||
|
},
|
||||||
|
"toml": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
||||||
|
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
||||||
|
],
|
||||||
|
"version": "==0.10.0"
|
||||||
|
},
|
||||||
|
"typed-ast": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161",
|
||||||
|
"sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
|
||||||
|
"sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
|
||||||
|
"sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
|
||||||
|
"sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
|
||||||
|
"sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47",
|
||||||
|
"sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
|
||||||
|
"sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
|
||||||
|
"sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
|
||||||
|
"sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
|
||||||
|
"sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2",
|
||||||
|
"sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e",
|
||||||
|
"sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
|
||||||
|
"sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
|
||||||
|
"sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
|
||||||
|
"sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
|
||||||
|
"sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
|
||||||
|
"sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
|
||||||
|
"sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66",
|
||||||
|
"sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
|
||||||
|
],
|
||||||
|
"version": "==1.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
local-manage.sh
Executable file
2
local-manage.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec python3 ./manage.py "$@" --settings=market.settings.local
|
||||||
21
manage.py
Executable file
21
manage.py
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'market.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()
|
||||||
0
market/__init__.py
Normal file
0
market/__init__.py
Normal file
16
market/asgi.py
Normal file
16
market/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for market project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'market.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
0
market/settings/__init__.py
Normal file
0
market/settings/__init__.py
Normal file
137
market/settings/common.py
Normal file
137
market/settings/common.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Django settings for market project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 3.0.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/3.0/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/3.0/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
|
BASE_DIR = str(
|
||||||
|
Path(__file__) # ./market/settings/common.py
|
||||||
|
.resolve() # PWD/market/settings/common.py
|
||||||
|
.parent # PWD/market/settings
|
||||||
|
.parent # PWD/market/
|
||||||
|
.parent # PWD/ (goal)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
# SECRET_KEY =
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"django.contrib.humanize",
|
||||||
|
|
||||||
|
# Our apps
|
||||||
|
"trading",
|
||||||
|
|
||||||
|
# Third party apps
|
||||||
|
"bootstrap4",
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
]
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "market.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "market.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",},
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = "trading.User"
|
||||||
|
LOGIN_URL = reverse_lazy("trading:login")
|
||||||
|
LOGIN_REDIRECT_URL = reverse_lazy("trading:index")
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/3.0/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
|
# Guardian object permissions can be found at
|
||||||
|
# https://django-guardian.readthedocs.io/en/stable/configuration.html
|
||||||
|
|
||||||
|
#GUARDIAN_RENDER_403 = True
|
||||||
|
#GUARDIAN_TEMPLATE_403 = "403.html"
|
||||||
|
|
||||||
|
# Disable anonymous user permissions
|
||||||
|
#ANONYMOUS_USER_NAME = None
|
||||||
|
|
||||||
|
# TODO: look at this if it makes sense to fetch all user permissions upon application startup
|
||||||
|
# (Probably not necessary)
|
||||||
|
# GUARDIAN_AUTO_PREFETCH = True
|
||||||
|
|
||||||
|
# This app's settings
|
||||||
|
from market.settings.defaults import *
|
||||||
18
market/settings/defaults.py
Normal file
18
market/settings/defaults.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Whether an invitation is required to join.
|
||||||
|
TRADING_INVITE_REQUIRED = True
|
||||||
|
|
||||||
|
# Number of invites that a user gets when they sign up.
|
||||||
|
TRADING_FREE_INVITES = 3
|
||||||
|
|
||||||
|
# Number of accounts that a new user is allowed to create.
|
||||||
|
# Use `None` for unlimited.
|
||||||
|
TRADING_FREE_ACCOUNTS = 5
|
||||||
|
|
||||||
|
# Number of commodities that a user gets when they sign up.
|
||||||
|
TRADING_FREE_COMMODITIES = 1
|
||||||
|
|
||||||
|
# Maximum number of commodities allowed for a single IPO
|
||||||
|
TRADING_IPO_MAX = 5000000
|
||||||
|
|
||||||
|
# Minimum number of commodities allowed for a single IPO
|
||||||
|
TRADING_IPO_MIN = 100
|
||||||
18
market/settings/local.py
Normal file
18
market/settings/local.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import os.path
|
||||||
|
from market.settings.common import *
|
||||||
|
|
||||||
|
SECRET_KEY = "65sb@2f)s03(%qcb)cs6-wexn1af&3&$o12pp7%4en_*p6(b-d"
|
||||||
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
TIME_ZONE = "US/Eastern"
|
||||||
|
|
||||||
|
STATICFILES_DIRS = [
|
||||||
|
os.path.join(BASE_DIR, "static"),
|
||||||
|
]
|
||||||
|
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
# pwgen -r '"'"'" -cyn 128
|
||||||
|
HASHID_FIELD_SALT = "h)1qE%eGhOZha_<`*2#$<m^,/K.w;I#s/Er,cpbad-?"
|
||||||
|
|
||||||
|
TRADING_FREE_ACCOUNTS = None
|
||||||
28
market/urls.py
Normal file
28
market/urls.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""market URL Configuration
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/3.0/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("", include("trading.urls")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Serve up static files using Django server if in DEBUG mode
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
16
market/wsgi.py
Normal file
16
market/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for market project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'market.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
7
static/trading/css/bootstrap.min.css
vendored
Normal file
7
static/trading/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
static/trading/js/bootstrap.min.js
vendored
Normal file
7
static/trading/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/trading/js/jquery-3.3.1.slim.min.js
vendored
Normal file
2
static/trading/js/jquery-3.3.1.slim.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
static/trading/js/popper.min.js
vendored
Normal file
5
static/trading/js/popper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
26
tests/test_models.py
Normal file
26
tests/test_models.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from trading.models import User, Commodity, Tx, BalanceError
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransactions(TestCase):
|
||||||
|
def test_user_balances(self):
|
||||||
|
# Create users
|
||||||
|
user1 = User.objects.create_user(username="test1", email="test1@test.test")
|
||||||
|
user2 = User.objects.create_user(username="test2", email="test2@test.test")
|
||||||
|
|
||||||
|
commodity = Commodity(created_by=user1, in_circulation=1000, name="commodity")
|
||||||
|
commodity.save()
|
||||||
|
|
||||||
|
self.assertEqual(user1.balances(), {commodity: 1000})
|
||||||
|
self.assertEqual(user2.balances(), {})
|
||||||
|
|
||||||
|
Tx(source=user1, dest=user2, amount=100, commodity=commodity).save()
|
||||||
|
|
||||||
|
self.assertEqual(user1.balances(), {commodity: 900})
|
||||||
|
self.assertEqual(user2.balances(), {commodity: 100})
|
||||||
|
|
||||||
|
with self.assertRaises(BalanceError):
|
||||||
|
Tx(source=user2, dest=user1, amount=1000, commodity=commodity).save()
|
||||||
|
|
||||||
|
self.assertEqual(user1.balances(), {commodity: 900})
|
||||||
|
self.assertEqual(user2.balances(), {commodity: 100})
|
||||||
0
trading/__init__.py
Normal file
0
trading/__init__.py
Normal file
3
trading/admin.py
Normal file
3
trading/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
trading/apps.py
Normal file
5
trading/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TradingConfig(AppConfig):
|
||||||
|
name = 'trading'
|
||||||
0
trading/forms.py
Normal file
0
trading/forms.py
Normal file
31
trading/managers.py
Normal file
31
trading/managers.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from django.contrib.auth.base_user import BaseUserManager
|
||||||
|
|
||||||
|
class UserManager(BaseUserManager):
|
||||||
|
use_in_migrations = True
|
||||||
|
|
||||||
|
def _create_user(self, username, email, password, **extra_fields):
|
||||||
|
"""
|
||||||
|
Creates and saves a User with the given email and password.
|
||||||
|
"""
|
||||||
|
if not email:
|
||||||
|
raise ValueError('The given email must be set')
|
||||||
|
if not username:
|
||||||
|
raise ValueError('The given username must be set')
|
||||||
|
# TODO: validate username
|
||||||
|
email = self.normalize_email(email)
|
||||||
|
user = self.model(username=username, email=email, **extra_fields)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save(using=self._db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_user(self, username, email, password=None, **extra_fields):
|
||||||
|
extra_fields.setdefault('is_superuser', False)
|
||||||
|
return self._create_user(username, email, password, **extra_fields)
|
||||||
|
|
||||||
|
def create_superuser(self, username, email, password, **extra_fields):
|
||||||
|
extra_fields.setdefault('is_superuser', True)
|
||||||
|
|
||||||
|
if extra_fields.get('is_superuser') is not True:
|
||||||
|
raise ValueError('Superuser must have is_superuser=True.')
|
||||||
|
|
||||||
|
return self._create_user(username, email, password, **extra_fields)
|
||||||
97
trading/migrations/0001_initial.py
Normal file
97
trading/migrations/0001_initial.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Generated by Django 2.2 on 2020-01-16 01:11
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import hashid_field.field
|
||||||
|
import trading.managers
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0011_update_proxy_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='User',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(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')),
|
||||||
|
('username', models.CharField(max_length=30, unique=True)),
|
||||||
|
('email', models.EmailField(max_length=254, unique=True)),
|
||||||
|
('is_superuser', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='date joined')),
|
||||||
|
('max_commodities_allowed', models.PositiveIntegerField(default=1, null=True, verbose_name='number of commodities this user is allowed to create')),
|
||||||
|
('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',
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', trading.managers.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Commodity',
|
||||||
|
fields=[
|
||||||
|
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=100, unique=True, verbose_name='commodity name')),
|
||||||
|
('in_circulation', models.PositiveIntegerField()),
|
||||||
|
('symbol', models.CharField(blank=True, max_length=6, unique=True, verbose_name='symbol')),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'commodity',
|
||||||
|
'verbose_name_plural': 'commodities',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TxRequests',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('WAIT', 'Waiting'), ('DECL', 'Declined'), ('REV', 'Revised')], default='WAIT', max_length=2)),
|
||||||
|
('source_amount', models.PositiveIntegerField()),
|
||||||
|
('dest_amount', models.PositiveIntegerField()),
|
||||||
|
('dest', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_received', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('dest_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
|
||||||
|
('previous_request', models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revised_request', to='trading.TxRequests')),
|
||||||
|
('source', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_sent', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('source_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tx',
|
||||||
|
fields=[
|
||||||
|
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
|
||||||
|
('instant', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('amount', models.PositiveIntegerField(editable=False, verbose_name='amount')),
|
||||||
|
('commodity', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to='trading.Commodity')),
|
||||||
|
('dest', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='credits', related_query_name='credit_tx', to=settings.AUTH_USER_MODEL, verbose_name='destination')),
|
||||||
|
('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='debits', related_query_name='debit_tx', to=settings.AUTH_USER_MODEL, verbose_name='source')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'transaction',
|
||||||
|
'verbose_name_plural': 'transactions',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Invite',
|
||||||
|
fields=[
|
||||||
|
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
|
||||||
|
('accepted_user', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='accepted user')),
|
||||||
|
('inviter', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'invite',
|
||||||
|
'verbose_name_plural': 'invites',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
trading/migrations/__init__.py
Normal file
0
trading/migrations/__init__.py
Normal file
279
trading/models.py
Normal file
279
trading/models.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Mapping, Optional, Sequence
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from frozendict import frozendict
|
||||||
|
from hashid_field import HashidAutoField
|
||||||
|
from trading.managers import UserManager
|
||||||
|
|
||||||
|
|
||||||
|
class User(PermissionsMixin, AbstractBaseUser):
|
||||||
|
"""
|
||||||
|
Base user model, with auth information already filled in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
username = models.CharField(max_length=30, unique=True)
|
||||||
|
email = models.EmailField(unique=True)
|
||||||
|
is_superuser = models.BooleanField(default=False)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
date_joined = models.DateTimeField(_("date joined"), auto_now_add=True)
|
||||||
|
|
||||||
|
max_commodities_allowed = models.PositiveIntegerField(
|
||||||
|
default=settings.TRADING_FREE_COMMODITIES,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("number of commodities this user is allowed to create"),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
|
USERNAME_FIELD = "username"
|
||||||
|
EMAIL_FIELD = "email"
|
||||||
|
REQUIRED_FIELDS = (EMAIL_FIELD,)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("user")
|
||||||
|
verbose_name_plural = _("users")
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
"""
|
||||||
|
Returns the first_name plus the last_name, with a space in between.
|
||||||
|
"""
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def get_short_name(self):
|
||||||
|
"""
|
||||||
|
Returns the short name for the user.
|
||||||
|
"""
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Sends an email to this User.
|
||||||
|
"""
|
||||||
|
send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||||
|
|
||||||
|
def can_create_commodity(self) -> bool:
|
||||||
|
if self.max_commodities_allowed is None:
|
||||||
|
return True
|
||||||
|
return self.max_commodities_allowed > self.commodity_set.count()
|
||||||
|
|
||||||
|
def unused_commodities(self) -> Optional[int]:
|
||||||
|
if self.max_commodities_allowed is None:
|
||||||
|
return None
|
||||||
|
return self.max_commodities_allowed - self.commodity_set.count()
|
||||||
|
|
||||||
|
def unused_invites(self) -> Sequence["Invite"]:
|
||||||
|
return self.invite_set.filter(accepted_user__isnull=True)
|
||||||
|
|
||||||
|
def balance_of(self, commodity: "Commodity") -> int:
|
||||||
|
return self.balances().get(commodity, 0)
|
||||||
|
|
||||||
|
def balances(self) -> Mapping["Commodity", int]:
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
credits = {
|
||||||
|
row["commodity"]: row["amount__sum"]
|
||||||
|
for row in self.credits.values("commodity").annotate(Sum("amount"))
|
||||||
|
}
|
||||||
|
debits = {
|
||||||
|
row["commodity"]: row["amount__sum"]
|
||||||
|
for row in self.debits.values("commodity").annotate(Sum("amount"))
|
||||||
|
}
|
||||||
|
ipos = {
|
||||||
|
row["pk"]: row["in_circulation"]
|
||||||
|
for row in self.commodity_set.values("pk", "in_circulation")
|
||||||
|
}
|
||||||
|
keys = set(ipos.keys()) | set(credits.keys()) | set(debits.keys())
|
||||||
|
return frozendict(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Commodity.objects.get(pk=pk),
|
||||||
|
credits.get(pk, 0) + ipos.get(pk, 0) - debits.get(pk, 0),
|
||||||
|
)
|
||||||
|
for pk in keys
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def _user_post_save(sender, instance, created, **kwargs):
|
||||||
|
# Create invites for the user, if they are permitted invites
|
||||||
|
if created and settings.TRADING_FREE_INVITES:
|
||||||
|
for _ in range(settings.TRADING_FREE_INVITES):
|
||||||
|
Invite.objects.create(inviter=instance).save()
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(models.Model):
|
||||||
|
"""
|
||||||
|
An invitation that is given out to a user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = HashidAutoField(primary_key=True)
|
||||||
|
inviter = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||||
|
accepted_user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
related_name="+",
|
||||||
|
verbose_name=_("accepted user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("invite")
|
||||||
|
verbose_name_plural = _("invites")
|
||||||
|
|
||||||
|
|
||||||
|
class Commodity(models.Model):
|
||||||
|
"""
|
||||||
|
A commodity with some kind of market cap.
|
||||||
|
|
||||||
|
A commodity has an associated "owner" account, which determines where the IPO for commodity
|
||||||
|
goes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = HashidAutoField(primary_key=True)
|
||||||
|
name = models.CharField(
|
||||||
|
blank=False, unique=True, max_length=100, verbose_name=_("commodity name")
|
||||||
|
)
|
||||||
|
in_circulation = models.PositiveIntegerField()
|
||||||
|
created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
||||||
|
symbol = models.CharField(
|
||||||
|
blank=True, unique=True, max_length=6, verbose_name=_("symbol")
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("commodity")
|
||||||
|
verbose_name_plural = _("commodities")
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return "/c/detail/%s" % self.pk
|
||||||
|
|
||||||
|
def html_symbol(self) -> str:
|
||||||
|
if self.symbol:
|
||||||
|
return f"<span>{self.symbol}</span>"
|
||||||
|
else:
|
||||||
|
return f"<span>units of {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class MaxCommodityError(Exception):
|
||||||
|
def __init__(self, user: User):
|
||||||
|
self.user = user
|
||||||
|
super().__init__("maximum commodity count reached")
|
||||||
|
|
||||||
|
|
||||||
|
class Tx(models.Model):
|
||||||
|
"""
|
||||||
|
A transaction from one account to another.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = HashidAutoField(primary_key=True)
|
||||||
|
instant = models.DateTimeField(auto_now_add=True, editable=False)
|
||||||
|
source = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="debits",
|
||||||
|
related_query_name="debit_tx",
|
||||||
|
verbose_name=_("source"),
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
dest = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="credits",
|
||||||
|
related_query_name="credit_tx",
|
||||||
|
verbose_name=_("destination"),
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
amount = models.PositiveIntegerField(verbose_name=_("amount"), editable=False)
|
||||||
|
commodity = models.ForeignKey(Commodity, on_delete=models.PROTECT, editable=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("transaction")
|
||||||
|
verbose_name_plural = _("transactions")
|
||||||
|
|
||||||
|
|
||||||
|
class TxRequests(models.Model):
|
||||||
|
"""
|
||||||
|
A transaction request between two users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
WAITING = "WAIT"
|
||||||
|
DECLINED = "DECL"
|
||||||
|
REVISED = "REV"
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(WAITING, "Waiting"),
|
||||||
|
(DECLINED, "Declined"),
|
||||||
|
(REVISED, "Revised"),
|
||||||
|
]
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=max([len(c) for c in STATUS_CHOICES]),
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default=WAITING,
|
||||||
|
)
|
||||||
|
previous_request = models.OneToOneField(
|
||||||
|
"TxRequests",
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="revised_request",
|
||||||
|
)
|
||||||
|
|
||||||
|
source = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
related_name="transaction_requests_sent",
|
||||||
|
)
|
||||||
|
dest = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
related_name="transaction_requests_received",
|
||||||
|
)
|
||||||
|
|
||||||
|
source_sends = models.ForeignKey(
|
||||||
|
Commodity, on_delete=models.CASCADE, related_name="+", null=True,
|
||||||
|
)
|
||||||
|
dest_sends = models.ForeignKey(
|
||||||
|
Commodity, on_delete=models.CASCADE, related_name="+", null=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
source_amount = models.PositiveIntegerField()
|
||||||
|
dest_amount = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self, balance: int, source: User, dest: User, amount: int, commodity: Commodity
|
||||||
|
) -> None:
|
||||||
|
self.balance = balance
|
||||||
|
self.source = source
|
||||||
|
self.dest = dest
|
||||||
|
self.amount = amount
|
||||||
|
self.commodity = commodity
|
||||||
|
super().__init__("balance too low to create transaction")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=Tx)
|
||||||
|
def _tx_pre_save(sender, instance, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Ensures that a transaction cannot be created if the source user doesn't have enough of the given
|
||||||
|
commodity in their account.
|
||||||
|
"""
|
||||||
|
if instance.pk is None:
|
||||||
|
balance = instance.source.balance_of(instance.commodity)
|
||||||
|
if instance.amount > balance:
|
||||||
|
raise BalanceError(
|
||||||
|
balance,
|
||||||
|
instance.source,
|
||||||
|
instance.dest,
|
||||||
|
instance.amount,
|
||||||
|
instance.commodity,
|
||||||
|
)
|
||||||
25
trading/templates/trading/account/login.html
Normal file
25
trading/templates/trading/account/login.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Login" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Log in</h4>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_messages %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% buttons submit="Log in" %}{% endbuttons %}
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{next}}"/>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
trading/templates/trading/account/password_change.html
Normal file
27
trading/templates/trading/account/password_change.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Password change" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Update your password</h4>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-primary" type="submit">Change password</button>
|
||||||
|
<a class="btn btn-link" href="{% url "trading:index" %}" role="button">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{next}}"/>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
trading/templates/trading/account/password_change_done.html
Normal file
27
trading/templates/trading/account/password_change_done.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Password change - success" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h2>Your password has been changed.</h2>
|
||||||
|
<p>
|
||||||
|
You will be redirected in 3 seconds.
|
||||||
|
<a href="{{next|default:"/"}}" target="_blank">Click here if you are not automatically redirected.</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
setTimeout(function() {
|
||||||
|
document.location = "{{next|default:"/"}}";
|
||||||
|
}, 3000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
24
trading/templates/trading/account/password_reset.html
Normal file
24
trading/templates/trading/account/password_reset.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Password reset" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4 class="text-center">Reset your password</h4>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% buttons submit="Reset password" %}{% endbuttons %}
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{next}}"/>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Password reset" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Success</h4>
|
||||||
|
Your password has successfully been reset. You may now use your new password to
|
||||||
|
<a href="{% url 'trading:login'%}"> log in</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Reset" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
{% if form %}
|
||||||
|
<h4 class="text-center">Reset your password</h4>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% buttons submit="Reset password" %}{% endbuttons %}
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{next}}"/>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<h4 class="text-center">Password reset error</h4>
|
||||||
|
<p>
|
||||||
|
The password reset link you are trying to use is either invalid
|
||||||
|
or has expired. If you need to reset your password,
|
||||||
|
<a href="{% url 'trading:password_reset' %}">click here</a> to
|
||||||
|
do so.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
20
trading/templates/trading/account/password_reset_done.html
Normal file
20
trading/templates/trading/account/password_reset_done.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Password reset" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Check your email</h4>
|
||||||
|
<p>
|
||||||
|
A password reset link has been sent to your email address. Please check your email
|
||||||
|
and follow the instructions to finish resetting your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
75
trading/templates/trading/account/settings.html
Normal file
75
trading/templates/trading/account/settings.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Settings" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h2>User settings</h2>
|
||||||
|
<strong> (<a href="{% url "trading:user_profile" object.id %}">view profile</a>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-primary" type="submit">Update</button>
|
||||||
|
</div>
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{next}}"/>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
<p>
|
||||||
|
<a href="{% url "trading:password_change" %}">Change your password here</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Invites</h4>
|
||||||
|
{% if object.unused_invites %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Invite #</th>
|
||||||
|
<th>Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for invite in object.invite_set.all %}
|
||||||
|
{% if not invite.accepted_user %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ forloop.counter }}</td>
|
||||||
|
<td>TODO Copy link script</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
No invites available.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
<script>
|
||||||
|
const copyToClipboard = str => {
|
||||||
|
const el = document.createElement('textarea');
|
||||||
|
el.value = str;
|
||||||
|
el.setAttribute('readonly', '');
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.left = '-9999px';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(el);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endcomment %}
|
||||||
34
trading/templates/trading/base.html
Normal file
34
trading/templates/trading/base.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% load staticfiles %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{# TODO l8n #}
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="{% static 'trading/css/bootstrap.min.css' %}" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T">
|
||||||
|
<title>{% block title %}Trading{% if title %} - {{ title }}{% endif %}{% endblock title %}</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "trading/navbar.html" %}
|
||||||
|
{% endblock navbar %}
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container mt-5"> <!-- -->
|
||||||
|
{% block messages %}
|
||||||
|
{% bootstrap_messages %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="{% static 'trading/js/jquery-3.3.1.slim.min.js' %}" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"></script>
|
||||||
|
<script src="{% static 'trading/js/popper.min.js' %}" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"></script>
|
||||||
|
<script src="{% static 'trading/js/bootstrap.min.js' %}" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"></script>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
trading/templates/trading/c/balances.html
Normal file
18
trading/templates/trading/c/balances.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% load humanize %}
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Commodity</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for commodity, balance in object.balances.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{commodity.name}}</td>
|
||||||
|
<td>{{balance|intcomma}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
40
trading/templates/trading/c/create.html
Normal file
40
trading/templates/trading/c/create.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Create commodity" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Create commodity</h4>
|
||||||
|
{% if object.can_create_commodity %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="alert alert-light">
|
||||||
|
<strong>Note:</strong> You have <strong>{{ object.unused_commodities|default:"unlimited" }}</strong>
|
||||||
|
{% if object.unused_commodities %}
|
||||||
|
commodit{{ object.unused_commodities|pluralize:"y,ies"}}
|
||||||
|
{% else %}
|
||||||
|
commodities
|
||||||
|
{% endif %} available to create.
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Create</button>
|
||||||
|
<a class="btn btn-link" href="{% url "trading:index" %}" role="button">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{next}}"/>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>You have created the maximum number of commodities you are allowed to create.</p>
|
||||||
|
<p><a href="{% url "trading:settings" %}">Click here to view your settings.</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
trading/templates/trading/c/detail.html
Normal file
16
trading/templates/trading/c/detail.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Commodity detail" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>{{object.name}}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
15
trading/templates/trading/index.html
Normal file
15
trading/templates/trading/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Balances</h4>
|
||||||
|
<p>{% include "trading/c/balances.html" with object=request.user %}</p>
|
||||||
|
{% if request.user.can_create_commodity %}
|
||||||
|
<p><a href="{% url "trading:commodity_create" %}">Create a new commodity</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
3
trading/templates/trading/navbar.html
Normal file
3
trading/templates/trading/navbar.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{% load staticfiles %}
|
||||||
|
<nav class="navbar navbar-expand-lg">
|
||||||
|
</nav>
|
||||||
51
trading/templates/trading/u/profile.html
Normal file
51
trading/templates/trading/u/profile.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "trading/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
{% load humanize %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% with title="Profile" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h2>{{ object.username }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<td>Status</td>
|
||||||
|
<td>{{ object_status }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date joined</td>
|
||||||
|
<td>{{ object.date_joined.date }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<h4>Commodities owned by this user</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Commodity</th>
|
||||||
|
<th>Circulation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for commodity in object.commodity_set.all %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url "trading:commodity_detail" commodity.pk %}">{{ commodity.name }}</a></td>
|
||||||
|
<td>{{ commodity.in_circulation|intcomma }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
3
trading/tests.py
Normal file
3
trading/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
77
trading/urls.py
Normal file
77
trading/urls.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from django.views.generic.base import RedirectView
|
||||||
|
from django.contrib.auth.views import *
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from trading.views import *
|
||||||
|
|
||||||
|
|
||||||
|
app_name = "trading"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", IndexView.as_view(), name="index"),
|
||||||
|
path("login/", RedirectView.as_view(url=reverse_lazy("trading:login"))),
|
||||||
|
path("logout/", RedirectView.as_view(url=reverse_lazy("trading:logout"))),
|
||||||
|
path(
|
||||||
|
"accounts/login/",
|
||||||
|
LoginView.as_view(template_name="trading/account/login.html",),
|
||||||
|
name="login",
|
||||||
|
),
|
||||||
|
path("accounts/logout/", LogoutView.as_view(), name="logout"),
|
||||||
|
path(
|
||||||
|
"accounts/password_reset/",
|
||||||
|
PasswordResetView.as_view(
|
||||||
|
template_name="trading/account/password_reset.html",
|
||||||
|
success_url=reverse_lazy("trading:password_reset_done"),
|
||||||
|
),
|
||||||
|
name="password_reset",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"accounts/password_reset_done/",
|
||||||
|
PasswordResetDoneView.as_view(
|
||||||
|
template_name="trading/account/password_reset_done.html"
|
||||||
|
),
|
||||||
|
name="password_reset_done",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"accounts/password_reset_confirm/",
|
||||||
|
PasswordResetConfirmView.as_view(
|
||||||
|
template_name="trading/account/password_reset_confirm.html",
|
||||||
|
success_url=reverse_lazy("trading:password_reset_complete"),
|
||||||
|
),
|
||||||
|
name="password_reset_confirm",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"accounts/password_reset_complete/",
|
||||||
|
PasswordResetCompleteView.as_view(
|
||||||
|
template_name="trading/account/password_reset_complete.html"
|
||||||
|
),
|
||||||
|
name="password_reset_complete",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"accounts/password_change/",
|
||||||
|
PasswordChangeView.as_view(
|
||||||
|
template_name="trading/account/password_change.html",
|
||||||
|
success_url=reverse_lazy("trading:password_change_done"),
|
||||||
|
),
|
||||||
|
name="password_change",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"accounts/password_change_done/",
|
||||||
|
PasswordChangeDoneView.as_view(
|
||||||
|
template_name="trading/account/password_change_done.html"
|
||||||
|
),
|
||||||
|
name="password_change_done",
|
||||||
|
),
|
||||||
|
path("accounts/settings/", UserSettingsView.as_view(), name="settings"),
|
||||||
|
|
||||||
|
# u/ for users
|
||||||
|
path("u/profile/<int:pk>/", UserProfileView.as_view(), name="user_profile"),
|
||||||
|
|
||||||
|
# t/ for tx
|
||||||
|
# c/ for commodities
|
||||||
|
path("c/create/", CommodityCreateView.as_view(), name="commodity_create"),
|
||||||
|
|
||||||
|
#path("c/list/", CommodityListView.as_view(), name="commodity_list"),
|
||||||
|
path("c/detail/<str:pk>/", CommodityDetailView.as_view(), name="commodity_detail"),
|
||||||
|
]
|
||||||
80
trading/views.py
Normal file
80
trading/views.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import views as auth_views, password_validation
|
||||||
|
from django.contrib.auth.forms import UsernameField
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import HttpResponseForbidden, HttpResponseRedirect
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.views.generic.detail import DetailView
|
||||||
|
from django.views.generic.edit import CreateView, UpdateView
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from trading.models import User, Commodity, MaxCommodityError
|
||||||
|
|
||||||
|
|
||||||
|
class IndexView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "trading/index.html"
|
||||||
|
|
||||||
|
|
||||||
|
class CommodityDetailView(DetailView):
|
||||||
|
template_name = "trading/c/detail.html"
|
||||||
|
model = Commodity
|
||||||
|
|
||||||
|
|
||||||
|
class CommodityCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
template_name = "trading/c/create.html"
|
||||||
|
model = Commodity
|
||||||
|
fields = (
|
||||||
|
"name",
|
||||||
|
"in_circulation",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["object"] = self.request.user
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
user = self.request.user
|
||||||
|
if not user.can_create_commodity():
|
||||||
|
raise MaxCommodityError(user)
|
||||||
|
form.instance.created_by = user
|
||||||
|
return super(CreateView, self).form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(DetailView):
|
||||||
|
template_name = "trading/u/profile.html"
|
||||||
|
model = User
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.object.is_active:
|
||||||
|
status = "Active"
|
||||||
|
else:
|
||||||
|
status = "Inactive"
|
||||||
|
context["object_status"] = status
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateSettingsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = (
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
)
|
||||||
|
field_classes = {"username": UsernameField}
|
||||||
|
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsView(LoginRequiredMixin, UpdateView):
|
||||||
|
template_name = "trading/account/settings.html"
|
||||||
|
model = User
|
||||||
|
fields = ("username", "email")
|
||||||
|
success_url = reverse_lazy("trading:settings")
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return self.request.user
|
||||||
Reference in New Issue
Block a user