diff --git a/.gitignore b/.gitignore index 4c49bd7..bbe9cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,112 @@ +# Django # +*.log +*.pot +*.pyc +__pycache__ +db.sqlite3 + +# Backup files # +*.bak + +# just going to ignore the whole .idea folder +.idea/ + +# File-based project format +*.iws + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Python # +*.py[cod] +*$py.class + +# Distribution / packaging +.Python build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments .env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Sublime Text # +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files Package +Control.last-run +Control.ca-list +Control.ca-bundle +Control.system-ca-bundle +GitHub.sublime-settings + +# Visual Studio Code # +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 5cb71ef..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/palguybuddydude.iml b/.idea/palguybuddydude.iml deleted file mode 100644 index 0e9dd5a..0000000 --- a/.idea/palguybuddydude.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/django-app/.dockerignore b/django-app/.dockerignore new file mode 100644 index 0000000..4ea7deb --- /dev/null +++ b/django-app/.dockerignore @@ -0,0 +1,6 @@ +# these can cause issues with the test runner when it is trying to collect the tests +*.pyc +**/__pycache__ + +# allows installing pipenv dependencies locally in a virtual environment +.venv diff --git a/django-app/.env.template b/django-app/.env.template new file mode 100644 index 0000000..24af7c7 --- /dev/null +++ b/django-app/.env.template @@ -0,0 +1,16 @@ +# debug +DEBUG=True +DEBUG_EXTENSIONS=True + +# list of allowed hosts +ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# path to postgres server when running locally. This value overwritten in docker-compose.yml +POSTGRES_HOST=0.0.0.0 +POSTGRES_PASSWORD=postgres +POSTGRES_DB=postgres +POSTGRES_USER=postgres +POSTGRES_PORT=5432 + +# django stuff +DJANGO_SECRET_KEY=SECRET_KEY_GOES_HERE diff --git a/django-app/.gitignore b/django-app/.gitignore new file mode 100644 index 0000000..0492696 --- /dev/null +++ b/django-app/.gitignore @@ -0,0 +1,8 @@ +# data for management commands +**/management/commands/data/ + +# media files location +app/media/ + +# static files location +app/static/ diff --git a/django-app/Dockerfile b/django-app/Dockerfile new file mode 100644 index 0000000..14ca9d4 --- /dev/null +++ b/django-app/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim as base +ENV PYTHONUNBUFFERED 1 + +# install pipenv and compilation dependencies +# https://cryptography.io/en/latest/installation/ +RUN apt-get update && apt-get install -y build-essential libssl-dev libffi-dev python3-dev cargo +RUN pip install --upgrade pip pipenv + +# install python dependencies +RUN mkdir /code +WORKDIR /code +COPY Pipfile /code +COPY Pipfile.lock /code +RUN pipenv install --dev --deploy --system + +# install application into container +COPY . /code + +# run application +EXPOSE 8000 + +WORKDIR /code/app + +# this CMD is overridden by the command in docker-compose.yml for local development +CMD /code/bin/wait-for-it.sh db:5432 -- python /code/app/manage.py runserver 0.0.0.0:8000 diff --git a/django-app/Pipfile b/django-app/Pipfile new file mode 100644 index 0000000..d1b65e0 --- /dev/null +++ b/django-app/Pipfile @@ -0,0 +1,19 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +django = "==5.0.2" +django-extensions = "==3.2.3" +psycopg2-binary = "==2.9.9" +stringcase = "==1.2.0" + +[dev-packages] +pytest = "==8.0.0" +pytest-cov = "==4.1.0" +pytest-django = "==4.8.0" +factory-boy = "==3.3.0" + +[requires] +python_version = "3.11.4" diff --git a/django-app/Pipfile.lock b/django-app/Pipfile.lock new file mode 100644 index 0000000..b45a224 --- /dev/null +++ b/django-app/Pipfile.lock @@ -0,0 +1,287 @@ +{ + "_meta": { + "hash": { + "sha256": "1308de52680d1b4eef7392a11288a0c9b72a45d5aa824f755e83fdafe94441fd" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.11.4" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "asgiref": { + "hashes": [ + "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", + "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.2" + }, + "django": { + "hashes": [ + "sha256:56ab63a105e8bb06ee67381d7b65fe6774f057e41a8bab06c8020c8882d8ecd4", + "sha256:b5bb1d11b2518a5f91372a282f24662f58f66749666b0a286ab057029f728080" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==5.0.2" + }, + "django-extensions": { + "hashes": [ + "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a", + "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.2.3" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9", + "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77", + "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e", + "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84", + "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3", + "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2", + "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67", + "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876", + "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152", + "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f", + "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a", + "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6", + "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503", + "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f", + "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493", + "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996", + "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f", + "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e", + "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59", + "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94", + "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7", + "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682", + "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420", + "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae", + "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291", + "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe", + "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980", + "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93", + "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692", + "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119", + "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716", + "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472", + "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b", + "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2", + "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc", + "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", + "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5", + "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", + "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984", + "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9", + "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf", + "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0", + "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f", + "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212", + "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb", + "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be", + "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90", + "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041", + "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7", + "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860", + "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d", + "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245", + "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27", + "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417", + "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359", + "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202", + "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0", + "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7", + "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba", + "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1", + "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd", + "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07", + "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98", + "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55", + "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d", + "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972", + "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f", + "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e", + "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26", + "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957", + "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53", + "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.9.9" + }, + "sqlparse": { + "hashes": [ + "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", + "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.4" + }, + "stringcase": { + "hashes": [ + "sha256:48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008" + ], + "index": "pypi", + "version": "==1.2.0" + } + }, + "develop": { + "coverage": { + "extras": [ + "toml" + ], + "hashes": [ + "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", + "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", + "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", + "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", + "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", + "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", + "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", + "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", + "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", + "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", + "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", + "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", + "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", + "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", + "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", + "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", + "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", + "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", + "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", + "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", + "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", + "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", + "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", + "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", + "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", + "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", + "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", + "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", + "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", + "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", + "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", + "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", + "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", + "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", + "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", + "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", + "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", + "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", + "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", + "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", + "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", + "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", + "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", + "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", + "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", + "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", + "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", + "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", + "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", + "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", + "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", + "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" + ], + "markers": "python_version >= '3.8'", + "version": "==7.4.1" + }, + "factory-boy": { + "hashes": [ + "sha256:a2cdbdb63228177aa4f1c52f4b6d83fab2b8623bf602c7dedd7eb83c0f69c04c", + "sha256:bc76d97d1a65bbd9842a6d722882098eb549ec8ee1081f9fb2e8ff29f0c300f1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==3.3.0" + }, + "faker": { + "hashes": [ + "sha256:0520a6b97e07c658b2798d7140971c1d5bc4bcd5013e7937fe075fd054aa320c", + "sha256:f07b64d27f67b62c7f0536a72f47813015b3b51cd4664918454011094321e464" + ], + "markers": "python_version >= '3.8'", + "version": "==23.2.1" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pluggy": { + "hashes": [ + "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", + "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "pytest": { + "hashes": [ + "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c", + "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==8.0.0" + }, + "pytest-cov": { + "hashes": [ + "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", + "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==4.1.0" + }, + "pytest-django": { + "hashes": [ + "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90", + "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.8.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" + }, + "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" + } + } +} diff --git a/django-app/README.md b/django-app/README.md new file mode 100644 index 0000000..efd53b7 --- /dev/null +++ b/django-app/README.md @@ -0,0 +1,42 @@ +# Django w/ Postgres starter + +* git clone the repo to your machine +* find and replace instances of `yourproject` with the name of your project +* `$ cd packages/django-app` +* `python -m venv .venv` + * not technically necessary, but useful for installing locally to add pip packages and update the requirements.txt file + * `$ source .venv/bin/activate` + * `$ pip install pipenv` + * `$ PIPENV_VENV_IN_PROJECT=1 pipenv install --dev --deploy` + * `$ pipenv install some_package` +* `$ cp .env.template .env` +* `$ docker-compose build` +* `$ ./utils/create-docker-volumes.sh` +* `$ ./bin/dcp-generate-secret-key.sh` + * copy and paste the output from this command into `.env` replacing `SECRET_KEY_GOES_HERE` +* `$ ./bin/dcp-django-admin.sh migrate` + +* now, you have two options + * create your own superuser + * `$ ./bin/dcp-django-admin.sh createsuperuser` + * load db w/ user admin@email:password + * `$ ./utils/reload-docker-db.sh --data=dev_data.json` + +* `$ docker-compose up web` +* you can now login with your superuser at 0.0.0.0:8000/admin + +## helpful scripts +* `$ ./utils/dcp-run-tests.sh` + * runs all tests, except those decorated with `@pytest.mark.integration` + * tests.py test_*.py *_test.py *_tests.py +* `$ ./bin/dcp-django-admin.sh` + * runs `manage.py` in the docker container with argument passthrough + * `$ ./bin/dcp-django-admin.sh makemigrations` + * `$ ./bin/dcp-django-admin.sh migrate` + * `$ ./bin/dcp-django-admin.sh startapp payments` +* `$ ./utils/reload-docker-db.sh` + * reloads `dev_data.json` by default + * `$ ./utils/reload-docker-db.sh --data=fixture_filename.json` +* `$ ./utils/dump-data.sh` + * `$ ./utils/dump-data.sh > app/core/fixtures/dump-2021-10-08.json` + * you can then reload these files with `./utils/reload-docker-db.sh` diff --git a/go.sum b/django-app/app/app/__init__.py similarity index 100% rename from go.sum rename to django-app/app/app/__init__.py diff --git a/django-app/app/app/asgi.py b/django-app/app/app/asgi.py new file mode 100644 index 0000000..3163a3a --- /dev/null +++ b/django-app/app/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for app 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/4.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_asgi_application() diff --git a/django-app/app/app/settings.py b/django-app/app/app/settings.py new file mode 100644 index 0000000..776b3ca --- /dev/null +++ b/django-app/app/app/settings.py @@ -0,0 +1,153 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 4.0. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.0/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ['DEBUG'] + +try: + ALLOWED_HOSTS = os.environ['ALLOWED_HOSTS'].split(',') +except KeyError: + ALLOWED_HOSTS = [] + +# auth +AUTH_USER_MODEL = 'core.User' +# authentication for django admin +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', +] + +# Application definition + +INSTALLED_APPS = [ + # django + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # contrib + 'django_extensions', + + # own + 'core', + 'palworld_core', +] + +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 = 'app.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 = 'app.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['POSTGRES_DB'], + 'USER': os.environ['POSTGRES_USER'], + 'PASSWORD': os.environ['POSTGRES_PASSWORD'], + 'HOST': os.environ['POSTGRES_HOST'], + 'PORT': os.environ['POSTGRES_PORT'], + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.0/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', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ + +STATIC_URL = os.environ.get('STATIC_URL', '/static/') + +# path to static directory in docker container. +# this is where files are created when running `collectstatic` +STATIC_ROOT = os.environ.get('STATIC_ROOT', os.path.join(BASE_DIR, 'static')) + +# https://docs.djangoproject.com/en/4.0/topics/files/ +MEDIA_URL = os.environ.get('MEDIA_URL', '/media/') +MEDIA_ROOT = os.environ.get('MEDIA_ROOT', os.path.join(BASE_DIR, 'media')) + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +SITE_URL = os.environ.get('SITE_URL', '0.0.0.0') diff --git a/django-app/app/app/test_settings.py b/django-app/app/app/test_settings.py new file mode 100644 index 0000000..70eecde --- /dev/null +++ b/django-app/app/app/test_settings.py @@ -0,0 +1,6 @@ +from .settings import * + +""" +Adds `common` app to installed apps so that test models are only created for tests. +""" +INSTALLED_APPS += ['common'] diff --git a/django-app/app/app/urls.py b/django-app/app/app/urls.py new file mode 100644 index 0000000..1e0fd9a --- /dev/null +++ b/django-app/app/app/urls.py @@ -0,0 +1,21 @@ +"""app URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.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.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/django-app/app/app/wsgi.py b/django-app/app/app/wsgi.py new file mode 100644 index 0000000..0efb709 --- /dev/null +++ b/django-app/app/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app 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/4.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_wsgi_application() diff --git a/django-app/app/common/commands/abstract_base_command.py b/django-app/app/common/commands/abstract_base_command.py new file mode 100644 index 0000000..bd83713 --- /dev/null +++ b/django-app/app/common/commands/abstract_base_command.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod + +from django.core.exceptions import ValidationError + +from common.forms.base_form import BaseForm + + +class AbstractBaseCommand(ABC): + """ + The Command interface declares a method for executing a command. + """ + + form: BaseForm + + @abstractmethod + def execute(self) -> None: + if not self.form.is_valid(): + raise ValidationError(self.form.errors.as_json()) diff --git a/django-app/app/common/forms/base_form.py b/django-app/app/common/forms/base_form.py new file mode 100644 index 0000000..1e4e5aa --- /dev/null +++ b/django-app/app/common/forms/base_form.py @@ -0,0 +1,39 @@ +from django import forms +from stringcase import snakecase + + +def snake_case_and_rename_id(key): + """ + snake cases key and renames to 'pk' if 'id', because 'id' shadows built in + """ + new_key = snakecase(key) + if new_key == 'id': + new_key = 'pk' + return new_key + + +class BaseForm(forms.Form): + def __init__(self, data): + """ + Snake cases all form keys. + Ex: `createdBy` -> `created_by` + """ + transformed_data = { + snake_case_and_rename_id(key): val for key, val in data.items() + } + super().__init__(transformed_data) + + def clean(self, *args, **kwargs): + cleaned_data = super().clean() + + # NOTE - Django forms will set non required fields to None or to an empty string if + # the form field is not passed into the form. + + # Filter for fields that are passed through the form and remove fields that where + # not passed into the form. + cleaned_data = { + form_field: cleaned_data[form_field] + for form_field in self.data if form_field in cleaned_data + } + + return cleaned_data diff --git a/django-app/app/common/forms/created_by_form_mixin.py b/django-app/app/common/forms/created_by_form_mixin.py new file mode 100644 index 0000000..d30b715 --- /dev/null +++ b/django-app/app/common/forms/created_by_form_mixin.py @@ -0,0 +1,16 @@ +from django import forms + +from common.forms.base_form import BaseForm +from core.repositories import UserRepository, AccountRepository + + +class CreatedByFormMixin(BaseForm): + created_by = forms.ModelChoiceField(queryset=UserRepository.get_queryset()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # created_by is not required when updating + form_name = type(self).__name__ + if form_name.startswith('Update'): + self.fields['created_by'].required = False diff --git a/django-app/app/common/management/commands/base_management_command.py b/django-app/app/common/management/commands/base_management_command.py new file mode 100644 index 0000000..c54ddec --- /dev/null +++ b/django-app/app/common/management/commands/base_management_command.py @@ -0,0 +1,27 @@ +from django.core.management.base import BaseCommand + + +class BaseManagementCommand(BaseCommand): + """ + This class if for containing logic that + we want shared across all management commands. + """ + + def add_arguments(self, parser): + """ + Add dry_run argument for every management command by default. + """ + parser.add_argument( + '--dry_run', + help='Run script with out saving changes to database.', + action='store_true' + ) + + def handle(self, *args, **options): + """ + The actual logic of the command. Subclasses must implement + this method. + + Copied directly from Django's code from django/core/management/base.py + """ + raise NotImplementedError('subclasses of BaseManagementCommand must provide a handle() method') diff --git a/django-app/app/common/management/helpers.py b/django-app/app/common/management/helpers.py new file mode 100644 index 0000000..cd46aa2 --- /dev/null +++ b/django-app/app/common/management/helpers.py @@ -0,0 +1,28 @@ +def get_file_contents(path): + """ + Read file at path and return contents. + Tries multiple encodings. + """ + encodings = ['utf-8', 'windows-1250', 'windows-1252'] # add more + file_reader = None + file_contents = None + for e in encodings: + try: + file_reader = open(path, 'r', encoding=e) + file_contents = file_reader.read() + except UnicodeDecodeError: + print(f'got unicode error with {e}, trying different encoding') + finally: + if file_reader: + file_reader.close() + + return file_contents + + +def write_file_contents(path, contents): + """ + Write file contents at path. + """ + f = open(path, 'w') + f.write(contents) + f.close() diff --git a/django-app/app/common/managers/soft_delete_model_manager.py b/django-app/app/common/managers/soft_delete_model_manager.py new file mode 100644 index 0000000..c5c11fa --- /dev/null +++ b/django-app/app/common/managers/soft_delete_model_manager.py @@ -0,0 +1,21 @@ +from django.db import models +from django.utils.timezone import now + + +class SoftDeleteQuerySet(models.query.QuerySet): + def delete(self, *args, **kwargs): + if kwargs.get('force_delete', None): + return super().delete() + + return super().update(is_active=False, deleted_at=now()) + + def undelete(self, *args, **kwargs): + return super().update(is_active=True, deleted_at=None) + + +class SoftDeleteModelManager(models.Manager): + """ + Custom manager for models that use SoftDeleteTimestampMixin. + """ + def get_queryset(self): + return SoftDeleteQuerySet(self.model) diff --git a/django-app/app/common/models/created_by_mixin.py b/django-app/app/common/models/created_by_mixin.py new file mode 100644 index 0000000..84c9d8a --- /dev/null +++ b/django-app/app/common/models/created_by_mixin.py @@ -0,0 +1,15 @@ +from django.db import models + + +class CreatedByMixin(models.Model): + """ + Adds `created_by` field. + """ + created_by = models.ForeignKey( + 'core.User', + db_index=True, + related_name='%(class)ss_created', # eg user.documents_created + on_delete=models.CASCADE) + + class Meta: + abstract = True diff --git a/django-app/app/common/models/crud_timestamps_mixin.py b/django-app/app/common/models/crud_timestamps_mixin.py new file mode 100644 index 0000000..6b35f90 --- /dev/null +++ b/django-app/app/common/models/crud_timestamps_mixin.py @@ -0,0 +1,13 @@ +from django.db import models + + +class CRUDTimestampsMixin(models.Model): + """ + `created_at` will be set on creation. + `modified_at` will be updated on saves. + """ + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + modified_at = models.DateTimeField(auto_now=True, db_index=True) + + class Meta: + abstract = True diff --git a/django-app/app/common/models/soft_delete_timestamp_mixin.py b/django-app/app/common/models/soft_delete_timestamp_mixin.py new file mode 100644 index 0000000..dca40cc --- /dev/null +++ b/django-app/app/common/models/soft_delete_timestamp_mixin.py @@ -0,0 +1,40 @@ +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from common.managers.soft_delete_model_manager import SoftDeleteModelManager + +is_active_help_text = _( + 'Designates whether this object should be treated as active. ' + 'Unselect this instead of deleting objects.' +) + + +class SoftDeleteTimestampMixin(models.Model): + """ + Set `is_active` to False instead of deleting rows in the database. + `deleted_at` will be set to the current time at time of deletion. + """ + deleted_at = models.DateTimeField(db_index=True, null=True) + is_active = models.BooleanField(_('active'), + db_index=True, + default=True, + help_text=is_active_help_text) + + objects = SoftDeleteModelManager() + + def delete(self, *args, **kwargs): + if kwargs.pop('force_delete', None): + super().delete(*args, **kwargs) + else: + self.deleted_at = timezone.now() + self.is_active = False + super().save() + + def undelete(self, *args, **kwargs): + self.is_active = True + self.deleted_at = None + super().save() + + class Meta: + abstract = True diff --git a/django-app/app/common/models/uuid_mixin.py b/django-app/app/common/models/uuid_mixin.py new file mode 100644 index 0000000..cd6f1b6 --- /dev/null +++ b/django-app/app/common/models/uuid_mixin.py @@ -0,0 +1,17 @@ +import uuid +from django.db import models +from django.template.defaultfilters import truncatechars + + +class UUIDModelMixin(models.Model): + """ + `uuid` field will be auto set with uuid4 values + """ + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + + class Meta: + abstract = True + + @property + def short_uuid(self): + return truncatechars(self.uuid, 8) diff --git a/django-app/app/common/repositories/base_repository.py b/django-app/app/common/repositories/base_repository.py new file mode 100644 index 0000000..b9fac7f --- /dev/null +++ b/django-app/app/common/repositories/base_repository.py @@ -0,0 +1,32 @@ +from common.models.soft_delete_timestamp_mixin import SoftDeleteTimestampMixin + +NOT_IMPLEMENTED_ERROR_MESSAGE = 'You must define a `model` on the inheriting Repository' + + +class BaseRepository: + model = None + + @classmethod + def get(cls, *, pk): + if not cls.model: + raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MESSAGE) + + try: + instance = cls.model.objects.get(pk=pk) + except cls.model.DoesNotExist: + return None + + return instance + + @classmethod + def get_queryset(cls, queryset=None): + if queryset is None: + if cls.model is None: + raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MESSAGE) + + if issubclass(cls.model, SoftDeleteTimestampMixin): + return cls.model.objects.filter(is_active=True) + + return cls.model.objects.all() + + return queryset diff --git a/django-app/app/core/__init__.py b/django-app/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-app/app/core/admin.py b/django-app/app/core/admin.py new file mode 100644 index 0000000..6711a6f --- /dev/null +++ b/django-app/app/core/admin.py @@ -0,0 +1,142 @@ +# Register your models here. +from core.models import User +from django import forms +from django.contrib import admin +from django.contrib.auth import password_validation +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +class UserCreationForm(forms.ModelForm): + """ + A form that creates a user, with no privileges, from the given username and + password. + """ + error_messages = { + 'password_mismatch': _("The two password fields didn't match."), + } + password1 = forms.CharField( + label=_("Password"), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + help_text=password_validation.password_validators_help_text_html(), + required=False, + ) + password2 = forms.CharField( + label=_("Password confirmation"), + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + strip=False, + help_text=_("Enter the same password as before, for verification."), + required=False, + ) + + class Meta: + model = User + fields = ("email",) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self._meta.model.USERNAME_FIELD in self.fields: + self.fields[self._meta.model.USERNAME_FIELD].widget.attrs['autofocus'] = True + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise ValidationError( + self.error_messages['password_mismatch'], + code='password_mismatch', + ) + return password2 + + def _post_clean(self): + super()._post_clean() + # Validate the password after self.instance is updated with form data + # by super(). + password = self.cleaned_data.get('password2') + if password: + try: + password_validation.validate_password(password, self.instance) + except ValidationError as error: + self.add_error('password2', error) + + def save(self, commit=True): + user = super().save(commit=False) + + if self.cleaned_data['password1']: + user.set_password(self.cleaned_data["password1"]) + + if commit: + user.save() + return user + + +class UserEditForm(UserChangeForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # NOTE - add fields that are not required here + # self.fields['activation_code'].required = False + + class Meta: + model = User + fields = '__all__' + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + form = UserEditForm + add_form = UserCreationForm + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ( + 'email', + 'password1', + 'password2', + 'is_superuser', + 'is_staff', + 'is_active', + ) + }),) + + fieldsets = ( + (None, {'fields': ('email', + 'password',)}), + + ('Permissions', {'fields': ('is_staff', + 'is_superuser', + 'groups', + 'user_permissions')}), + + ('Important', {'fields': ('is_active', + 'imported_at', + 'created_at', + 'modified_at', + 'deleted_at')}), + ) + + list_display = [ + 'id', + 'is_active', + 'is_staff', + 'is_superuser', + 'email', + ] + + list_display_links = ['id', 'email'] + + list_filter = [ + 'is_staff', + 'is_superuser', + 'is_active', + ] + + ordering = ['id'] + + readonly_fields = ['created_at', 'deleted_at', 'modified_at'] + + search_fields = ( + 'email', + ) diff --git a/django-app/app/core/apps.py b/django-app/app/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/django-app/app/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/django-app/app/core/fixtures/dev_data.json b/django-app/app/core/fixtures/dev_data.json new file mode 100644 index 0000000..cf112f3 --- /dev/null +++ b/django-app/app/core/fixtures/dev_data.json @@ -0,0 +1,280 @@ +[ +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "admin", + "model": "logentry" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "permission" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add group", + "content_type": [ + "auth", + "group" + ], + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change group", + "content_type": [ + "auth", + "group" + ], + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete group", + "content_type": [ + "auth", + "group" + ], + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view group", + "content_type": [ + "auth", + "group" + ], + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add session", + "content_type": [ + "sessions", + "session" + ], + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change session", + "content_type": [ + "sessions", + "session" + ], + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete session", + "content_type": [ + "sessions", + "session" + ], + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view session", + "content_type": [ + "sessions", + "session" + ], + "codename": "view_session" + } +}, +{ + "model": "core.user", + "fields": { + "password": "pbkdf2_sha256$320000$0Qwlf7cl4AOYvOu8cWne6p$JW9Nhv9/bZkxJv5jUNLMwYAZmwgf6lU25/hmdEEuDtI=", + "last_login": "2021-12-10T05:52:39.842Z", + "is_superuser": true, + "created_at": "2021-12-10T05:51:01.230Z", + "modified_at": "2021-12-10T05:51:01.230Z", + "deleted_at": null, + "is_active": true, + "email": "admin@email.com", + "is_staff": true, + "groups": [], + "user_permissions": [] + } +} +] diff --git a/django-app/app/core/helpers.py b/django-app/app/core/helpers.py new file mode 100644 index 0000000..f332e7e --- /dev/null +++ b/django-app/app/core/helpers.py @@ -0,0 +1,46 @@ +import functools +import random +import secrets +import string + + +def generate_membership_token(): + return secrets.token_urlsafe() + + +def generate_email_activation_code(): + """ + Generate Email Activation Token to be sent for mobile devices + example: ZSDF123 + """ + token = ''.join(random.choice(string.ascii_uppercase) for i in range(4)) + return token + str(random.randint(111, 999)) + + +def get_random_password(): + letters = string.ascii_letters + string.punctuation + result_str = 'A1!' + ''.join(random.choice(letters) for i in range(10)) + return result_str + + +def generate_signup_key(): + letters = string.ascii_uppercase + string.digits + res = ''.join(random.choice(letters) for i in range(10)) + return res + + +def rgetattr(obj, attr, *args): + """ + Recursive get attribute. + Get attr from obj. attr can be nested. + Returns None if attribute does not exist. + + Ex: val = rgetattr(obj, 'some.nested.property') + """ + + def _getattr(obj, attr): + if hasattr(obj, attr): + return getattr(obj, attr, *args) + return None + + return functools.reduce(_getattr, [obj] + attr.split('.')) diff --git a/django-app/app/core/managers/__init__.py b/django-app/app/core/managers/__init__.py new file mode 100644 index 0000000..8227174 --- /dev/null +++ b/django-app/app/core/managers/__init__.py @@ -0,0 +1 @@ +from core.managers.user_manager import UserManager diff --git a/django-app/app/core/managers/user_manager.py b/django-app/app/core/managers/user_manager.py new file mode 100644 index 0000000..d099ec1 --- /dev/null +++ b/django-app/app/core/managers/user_manager.py @@ -0,0 +1,58 @@ +from django.contrib.auth.base_user import BaseUserManager + +from core.helpers import generate_signup_key + + +class UserManager(BaseUserManager): + """ + Manager used for creating users. + """ + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + # email = self.normalize_email(email) + if not email: + raise ValueError('The email must be set') + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + """ + Creates and saves a :class:`User` + with the given email and password. + + :param email: :class:`python:str` or :func:`python:unicode` + Email + :param password: :class:`python:str` or :func:`python:unicode` + Password + :param extra_fields: :class:`python:dict` + Additional pairs of attribute with value to be set on + :class:`User` instance. + :return: Instance of created :class:`User` + :rtype: :class:`core.models.User` + """ + extra_fields.setdefault('is_superuser', False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + """ + Creates and saves a :class:`User` + with the given email, password and superuser privileges. + + :param email: :class:`python:str` + Nickname + :param password: :class:`python:str` + Password + :param extra_fields: :class:`python:dict` + Additional pairs of attribute with value to be set on + :class:`User` instance. + :return: Instance of created :class:`User` + :rtype: :class:`core.models.User` + """ + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_active', True) + extra_fields.setdefault('is_staff', True) + + return self._create_user(email, password, **extra_fields) diff --git a/django-app/app/core/migrations/0001_initial.py b/django-app/app/core/migrations/0001_initial.py new file mode 100644 index 0000000..3dff95b --- /dev/null +++ b/django-app/app/core/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.0 on 2021-12-10 05:27 + +import core.managers.user_manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + 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')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified_at', models.DateTimeField(auto_now=True, db_index=True)), + ('deleted_at', models.DateTimeField(db_index=True, null=True)), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Designates whether this object should be treated as active. Unselect this instead of deleting objects.', verbose_name='active')), + ('email', models.EmailField(max_length=255, unique=True, verbose_name='email')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('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={ + 'db_table': 'users', + 'ordering': ('id',), + 'default_permissions': (), + }, + managers=[ + ('objects', core.managers.user_manager.UserManager()), + ], + ), + ] diff --git a/django-app/app/core/migrations/__init__.py b/django-app/app/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-app/app/core/models/__init__.py b/django-app/app/core/models/__init__.py new file mode 100644 index 0000000..2219a54 --- /dev/null +++ b/django-app/app/core/models/__init__.py @@ -0,0 +1 @@ +from core.models.user import User diff --git a/django-app/app/core/models/user.py b/django-app/app/core/models/user.py new file mode 100644 index 0000000..83bdf06 --- /dev/null +++ b/django-app/app/core/models/user.py @@ -0,0 +1,35 @@ +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from common.models.crud_timestamps_mixin import CRUDTimestampsMixin +from common.models.soft_delete_timestamp_mixin import SoftDeleteTimestampMixin +from core.managers import UserManager + + +class User(CRUDTimestampsMixin, + SoftDeleteTimestampMixin, + AbstractBaseUser, + PermissionsMixin): + USERNAME_FIELD = 'email' + objects = UserManager() + + email = models.EmailField( + verbose_name='email', + max_length=255, + unique=True, + ) + + is_staff = models.BooleanField( + _('staff status'), + default=False, + help_text=_('Designates whether the user can log into this admin site.')) + + def __str__(self): + return self.email + + class Meta: + db_table = 'users' + default_permissions = () + unique_together = [] + ordering = ('id',) diff --git a/django-app/app/core/repositories/user_repository.py b/django-app/app/core/repositories/user_repository.py new file mode 100644 index 0000000..4fa9248 --- /dev/null +++ b/django-app/app/core/repositories/user_repository.py @@ -0,0 +1,29 @@ +from common.repositories.base_repository import BaseRepository +from core.models import User + + +class UserRepository(BaseRepository): + model = User + + @classmethod + def get_by_filter(cls, filter_input: dict = None): + if filter_input: + objects = cls.get_queryset().filter(**filter_input) + else: + objects = cls.get_queryset().all() + return objects + + @classmethod + def create(cls, data: dict) -> 'User': + user = cls.model.objects.create(**data) + return user + + @classmethod + def update(cls, *, pk=None, obj: 'User' = None, data: dict) -> 'User': + user = obj or cls.get(pk=pk) + + if data.get('is_active'): + user.is_active = data['is_active'] + + user.save() + return user diff --git a/django-app/app/core/tests.py b/django-app/app/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/django-app/app/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/django-app/app/core/views.py b/django-app/app/core/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/django-app/app/core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/django-app/app/manage.py b/django-app/app/manage.py new file mode 100755 index 0000000..4931389 --- /dev/null +++ b/django-app/app/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', 'app.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/django-app/app/palworld_analytics/__init__.py b/django-app/app/palworld_analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-app/app/palworld_analytics/admin.py b/django-app/app/palworld_analytics/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/django-app/app/palworld_analytics/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/django-app/app/palworld_analytics/apps.py b/django-app/app/palworld_analytics/apps.py new file mode 100644 index 0000000..1aa9590 --- /dev/null +++ b/django-app/app/palworld_analytics/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PalworldAnalyticsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'palworld_analytics' diff --git a/django-app/app/palworld_analytics/migrations/__init__.py b/django-app/app/palworld_analytics/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-app/app/palworld_analytics/models.py b/django-app/app/palworld_analytics/models.py new file mode 100644 index 0000000..a652f5c --- /dev/null +++ b/django-app/app/palworld_analytics/models.py @@ -0,0 +1,11 @@ +from django.db import models + + +class PalworldPlayerCheckIn(models.Model): + player = models.ForeignKey('PalworldPlayer', on_delete=models.CASCADE) + check_in_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'palworld_player_check_ins' + verbose_name = 'Palworld Player Check In' + verbose_name_plural = 'Palworld Player Check Ins' diff --git a/django-app/app/palworld_analytics/tests.py b/django-app/app/palworld_analytics/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/django-app/app/palworld_analytics/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/django-app/app/palworld_analytics/views.py b/django-app/app/palworld_analytics/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/django-app/app/palworld_analytics/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/django-app/app/palworld_core/__init__.py b/django-app/app/palworld_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-app/app/palworld_core/admin.py b/django-app/app/palworld_core/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/django-app/app/palworld_core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/django-app/app/palworld_core/apps.py b/django-app/app/palworld_core/apps.py new file mode 100644 index 0000000..68421c8 --- /dev/null +++ b/django-app/app/palworld_core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PalworldCoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'palworld_core' diff --git a/django-app/app/palworld_core/migrations/0001_initial.py b/django-app/app/palworld_core/migrations/0001_initial.py new file mode 100644 index 0000000..7dde062 --- /dev/null +++ b/django-app/app/palworld_core/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.2 on 2024-02-17 18:55 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PalworldPlayer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified_at', models.DateTimeField(auto_now=True, db_index=True)), + ('deleted_at', models.DateTimeField(db_index=True, null=True)), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Designates whether this object should be treated as active. Unselect this instead of deleting objects.', verbose_name='active')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('player_uid', models.CharField(max_length=255, unique=True)), + ('steam_id', models.CharField(max_length=255, unique=True)), + ('display_name', models.CharField(max_length=255, unique=True)), + ('first_seen_at', models.DateTimeField(auto_now_add=True)), + ('last_seen_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Palworld Player', + 'verbose_name_plural': 'Palworld Players', + 'db_table': 'palworld_players', + }, + ), + ] diff --git a/django-app/app/palworld_core/migrations/__init__.py b/django-app/app/palworld_core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-app/app/palworld_core/models.py b/django-app/app/palworld_core/models.py new file mode 100644 index 0000000..d8bc8be --- /dev/null +++ b/django-app/app/palworld_core/models.py @@ -0,0 +1,24 @@ +from django.db import models + +from common.models.crud_timestamps_mixin import CRUDTimestampsMixin +from common.models.soft_delete_timestamp_mixin import SoftDeleteTimestampMixin +from common.models.uuid_mixin import UUIDModelMixin + + +class PalworldPlayer(UUIDModelMixin, CRUDTimestampsMixin, SoftDeleteTimestampMixin): + player_uid = models.CharField(max_length=255, unique=True) + steam_id = models.CharField(max_length=255, unique=True) + display_name = models.CharField(max_length=255, unique=True) + + first_seen_at = models.DateTimeField(auto_now_add=True) + last_seen_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'palworld_players' + verbose_name = 'Palworld Player' + verbose_name_plural = 'Palworld Players' + + @property + def is_online(self): + # FIXME - this is just a placeholder for now; logic not correct. + return self.last_seen_at > self.first_seen_at diff --git a/django-app/app/palworld_core/test_helpers.py b/django-app/app/palworld_core/test_helpers.py new file mode 100644 index 0000000..252038b --- /dev/null +++ b/django-app/app/palworld_core/test_helpers.py @@ -0,0 +1,22 @@ +import factory + +from factory.django import DjangoModelFactory + +from palworld_core.models import PalworldPlayer + + +class PalworldPlayerFactory(DjangoModelFactory): + class Meta: + model = PalworldPlayer + + player_uid = factory.Faker('uuid4') + steam_id = factory.Faker('uuid4') + display_name = factory.Faker('name') + first_seen_at = factory.Faker('date_time') + last_seen_at = factory.Faker('date_time') + + uuid = factory.Faker('uuid4') + created_at = factory.Faker('date_time') + modified_at = factory.Faker('date_time') + deleted_at = factory.Faker('date_time') + is_active = factory.Faker('boolean') diff --git a/django-app/app/palworld_core/tests.py b/django-app/app/palworld_core/tests.py new file mode 100644 index 0000000..ceb0645 --- /dev/null +++ b/django-app/app/palworld_core/tests.py @@ -0,0 +1,15 @@ +from django.test import TestCase + +from palworld_core.test_helpers import PalworldPlayerFactory + + +class TestPalworldPlayerModel(TestCase): + @classmethod + def setUpTestData(cls): + cls.player = PalworldPlayerFactory(last_seen_at='2021-01-02 00:00:00', first_seen_at='2021-01-01 00:00:00') + + def test_player_is_online(self): + self.assertTrue(self.player.is_online) + + def test_player_is_offline(self): + self.player = PalworldPlayerFactory(last_seen_at='2021-01-01 00:00:00', first_seen_at='2021-01-01 00:00:00') diff --git a/django-app/app/palworld_core/views.py b/django-app/app/palworld_core/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/django-app/app/palworld_core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/django-app/bin/dcp-django-admin.sh b/django-app/bin/dcp-django-admin.sh new file mode 100755 index 0000000..fd901cd --- /dev/null +++ b/django-app/bin/dcp-django-admin.sh @@ -0,0 +1,35 @@ +#! /bin/bash + +# proxy to execute `manage.py` (django-admin) commands in web container + +function checkenv() { + ############################################################## + # check user's confidence if we are not using local database # + ############################################################## + + # get db host envar from docker container + DB_HOST_ENVAR=$(docker-compose run --rm -w /code/app web env | grep POSTGRES_HOST) + DB_HOST=$(cut -d "=" -f2 <<< "$DB_HOST_ENVAR") + # bashism to trim newline + DB_HOST=${DB_HOST//[$'\t\r\n']} + if [ "$DB_HOST" != 'db' ] && [ "$DB_HOST" != '0.0.0.0' ] && [ "$DB_HOST" != 'localhost' ] + then + echo "You are running this command against the database at ${DB_HOST}!" + checkconfidence + else + echo "Running command against database at $DB_HOST..." + fi +} + +function checkconfidence() { + read -r -p "Are you sure you want to continue? [y/N] " response + if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]] + then + return + else + exit + fi +} + +checkenv +docker-compose run --rm -w /code/app web /code/app/manage.py "$@" diff --git a/django-app/bin/dcp-exec.sh b/django-app/bin/dcp-exec.sh new file mode 100755 index 0000000..0454a16 --- /dev/null +++ b/django-app/bin/dcp-exec.sh @@ -0,0 +1,5 @@ +#! /bin/bash + +# proxy to execute shell commands in running web container + +docker-compose exec web "$@" diff --git a/django-app/bin/dcp-run-tests.sh b/django-app/bin/dcp-run-tests.sh new file mode 100755 index 0000000..0f97f7e --- /dev/null +++ b/django-app/bin/dcp-run-tests.sh @@ -0,0 +1,5 @@ +#! /bin/bash + +# proxy to execute pytest in web container + +docker-compose run --rm -w /code/app web pytest -m "not integration" --cov=. --verbose diff --git a/django-app/bin/dcp-run.sh b/django-app/bin/dcp-run.sh new file mode 100755 index 0000000..b087390 --- /dev/null +++ b/django-app/bin/dcp-run.sh @@ -0,0 +1,6 @@ +#! /bin/bash + +# proxy to run shell commands in web container. +# `run --rm` starts and runs container, then removes itself to cleanup + +docker-compose run --rm web "$@" diff --git a/django-app/bin/wait-for-it.sh b/django-app/bin/wait-for-it.sh new file mode 100755 index 0000000..d990e0d --- /dev/null +++ b/django-app/bin/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/django-app/docker-compose.yml b/django-app/docker-compose.yml new file mode 100644 index 0000000..74a3c50 --- /dev/null +++ b/django-app/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +volumes: + pgbd_postgres: + external: true + +services: + db: + image: postgres:15.4 + env_file: + - ./.env + ports: + - "5432:5432" + volumes: + - pgbd_postgres:/var/lib/postgresql/data/ + web: + build: + context: . + dockerfile: Dockerfile + env_file: + - ./.env + environment: + - POSTGRES_HOST=db + - STATIC_ROOT=/code/app/static/ + - MEDIA_ROOT=/code/app/media/ + command: + - '/code/bin/wait-for-it.sh' + - 'db:5432' + - '--' + - 'python' + - '/code/app/manage.py' + - 'runserver' + - '0.0.0.0:8000' + volumes: + - .:/code + ports: + - "8000:8000" + depends_on: + - db diff --git a/django-app/justfile b/django-app/justfile new file mode 100644 index 0000000..a7c3483 --- /dev/null +++ b/django-app/justfile @@ -0,0 +1,48 @@ +default: + @just --list + +set dotenv-load +set fallback + +default_port := "8000" + +# django admin +dj +commands: + cd app && DJANGO_SETTINGS_MODULE=app.settings \ + ../.venv/bin/python manage.py {{commands}} + +# run dev server +run port=default_port: + cd app && DJANGO_SETTINGS_MODULE=app.settings \ + ../.venv/bin/python manage.py runserver {{port}} + +# test +test: + cd app && DJANGO_SETTINGS_MODULE=app.settings \ + ../.venv/bin/pytest -m "not integration" --cov=. + +# generate a secret key for django +generate-secret-key: + cd app && DJANGO_SETTINGS_MODULE=app.settings \ + ../.venv/bin/python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' + + +################## +# Docker Compose # +################## + +# django-admin via dcp container +dcp-django-admin +commands: + docker-compose run --rm -w /code/app web /code/app/manage.py {{commands}} + +# bring up web app container +dcp-run: + docker-compose up web + +# test in dcp container +dcp-test: + docker-compose run --rm -w /code/app web pytest -m "not integration" --cov=. --verbose + +# generate a secret key using dcp container +dcp-generate-secret-key: + docker-compose run --rm -w /code/app web python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' diff --git a/django-app/pytest.ini b/django-app/pytest.ini new file mode 100644 index 0000000..d4b83e6 --- /dev/null +++ b/django-app/pytest.ini @@ -0,0 +1,12 @@ +[pytest] +DJANGO_SETTINGS_MODULE = app.test_settings +# -- recommended but optional: +python_files = tests.py test_*.py *_test.py *_tests.py + +; dynamodb_host = '0.0.0.0' +; dynamodb_port = '9000' + +addopts = --reuse-db + +markers = + integration diff --git a/django-app/utils/create-docker-volumes.sh b/django-app/utils/create-docker-volumes.sh new file mode 100755 index 0000000..b237c97 --- /dev/null +++ b/django-app/utils/create-docker-volumes.sh @@ -0,0 +1,4 @@ +#! /bin/bash + +# creates named volume +docker volume create --name=pgbd_postgres diff --git a/django-app/utils/dump-data.sh b/django-app/utils/dump-data.sh new file mode 100755 index 0000000..e5eeeb8 --- /dev/null +++ b/django-app/utils/dump-data.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +# script that dumps data from postgres + +docker-compose run --rm -w /code/app web /code/app/manage.py dumpdata \ + --natural-primary \ + --natural-foreign \ + --exclude=admin.logentry \ + --exclude=sessions.session \ + --indent 4 diff --git a/django-app/utils/reload-docker-db.sh b/django-app/utils/reload-docker-db.sh new file mode 100755 index 0000000..b084f11 --- /dev/null +++ b/django-app/utils/reload-docker-db.sh @@ -0,0 +1,107 @@ +#! /bin/bash + +data_to_load="dev_data.json" + +function usage() { + echo "Use this script to recreate the Docker volume and container for maya_postgres." + echo "It also runs migrations and loads initial data according to \`--data\`," + echo "or \`dev_data.json\` by default if no \`--data\` parameter specified." + echo "" + echo "Usage:" + echo "./utils/reload-docker-db.sh --help" + echo "./utils/reload-docker-db.sh" + echo "./utils/reload-docker-db.sh --data=demo_data.json" + echo "" +} + +function checkenv() { + ############################################################## + # check user's confidence if we are not using local database # + ############################################################## + + # get db host envar from docker container + DB_HOST_ENVAR=$(docker-compose run --rm -w /code/app web env | grep POSTGRES_HOST) + DB_HOST=$(cut -d "=" -f2 <<< "$DB_HOST_ENVAR") + # bashism to trim newline + DB_HOST=${DB_HOST//[$'\t\r\n']} + if [ "$DB_HOST" != 'db' ] && [ "$DB_HOST" != '0.0.0.0' ] && [ "$DB_HOST" != 'localhost' ] + then + echo "You are trying to reload the database at ${DB_HOST}!" + checkconfidence + else + echo "Reloading database at $DB_HOST" + fi +} + +function checkconfidence() { + read -r -p "Are you sure you want to continue? [y/N] " response + if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]] + then + return + else + exit + fi +} + +function recreate() { + # make sure db container is stopped + docker-compose stop db + + # deletes postgres docker container + docker-compose rm -f db + + # deletes postgres volume + docker volume rm pgbd_postgres + + # recreates named volume + docker volume create --name=pgbd_postgres + + # bring container back up and sleep for 5 seconds to ensure db is up + docker-compose up -d db + sleep 5 +} + +function migrate() { + # run migrations + docker-compose run --rm -w /code/app web /code/app/manage.py migrate +} + +function loaddata() { + # load data from fixture + docker-compose run --rm -w /code/app web /code/app/manage.py loaddata $data_to_load +} + +while [ "$1" != "" ]; do + PARAM=$(echo $1 | awk -F= '{print $1}') + VALUE=$(echo $1 | awk -F= '{print $2}') + case $PARAM in + -h | --help) + usage + exit + ;; + --data) + data_to_load=$VALUE + ;; + *) + echo "ERROR: unknown parameter \"$PARAM\"" + usage + exit 1 + ;; + esac + shift +done + + +checkenv +echo "starting script ..." +echo "Reloading $DB_HOST w/ $data_to_load ..." +echo "" + +echo "recreating volume and container ..." +recreate + +echo "running migrations ..." +migrate + +echo "loading data from ${data_to_load} ..." +loaddata diff --git a/docker-compose.yaml b/docker-compose.yaml index d23bc32..1c2540b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,20 +2,21 @@ services: palguybuddydude: depends_on: - palworld-rcon-buddy - build: . + restart: unless-stopped + build: ./palguybuddydude/. container_name: palguybuddydude env_file: - - ./.env + - palguybuddydude/.env palworld-rcon-buddy: - image: "valamidev/palworld-rcon-buddy:latest" - container_name: palworld-rcon-buddy - environment: - PALWORLD_RCON_PORT: "25575" - INFO_CACHE_DURATION_MS: 5000 # By Default /info end-point is cached for 5 seconds - BEARER_TOKEN: "" # we're not using an endpoint that requires auth, so doesn't matter right now - PORT: 3000 # RCON-BUDDY port - env_file: - - ./.env - ports: - - "3000:3000" + restart: unless-stopped + image: "valamidev/palworld-rcon-buddy:latest" + container_name: palworld-rcon-buddy + environment: + INFO_CACHE_DURATION_MS: 5000 # By Default /info end-point is cached for 5 seconds + BEARER_TOKEN: "" # we're not using an endpoint that requires auth, so doesn't matter right now + PORT: 3000 # RCON-BUDDY port + env_file: + - palguybuddydude/.env + ports: + - "3000:3000" diff --git a/.env.example b/palguybuddydude/.env.example similarity index 75% rename from .env.example rename to palguybuddydude/.env.example index c82cc79..21b0f69 100644 --- a/.env.example +++ b/palguybuddydude/.env.example @@ -2,7 +2,8 @@ RCON_BUDDY_HOST=172.17.0.1:3000 DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1234/abcd1234 -# This is how you can refference the palworld container +# This is how you can reference the palworld container PALWORLD_SERVER_IP_ADDRESS=127.0.0.1 # RCON Password PALWORLD_RCON_PASSWORD=yourrconpassword +PALWORLD_RCON_PORT=25575 diff --git a/Dockerfile b/palguybuddydude/Dockerfile similarity index 92% rename from Dockerfile rename to palguybuddydude/Dockerfile index a10e12f..491d88a 100644 --- a/Dockerfile +++ b/palguybuddydude/Dockerfile @@ -6,7 +6,7 @@ COPY go.mod go.sum ./ RUN go mod download -COPY . . +COPY .. . RUN go build main.go diff --git a/go.mod b/palguybuddydude/go.mod similarity index 100% rename from go.mod rename to palguybuddydude/go.mod diff --git a/palguybuddydude/go.sum b/palguybuddydude/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/justfile b/palguybuddydude/justfile similarity index 86% rename from justfile rename to palguybuddydude/justfile index 530d047..de0648a 100644 --- a/justfile +++ b/palguybuddydude/justfile @@ -2,6 +2,7 @@ default: @just --list set dotenv-load +set fallback # run go binary run: @@ -19,4 +20,4 @@ refresh: docker-compose up -d logs: - docker-compose logs -f \ No newline at end of file + docker-compose logs -f diff --git a/main.go b/palguybuddydude/main.go similarity index 98% rename from main.go rename to palguybuddydude/main.go index 5aee816..f17fe9b 100644 --- a/main.go +++ b/palguybuddydude/main.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "os" @@ -31,7 +31,7 @@ func fetchServerInfo(apiURL string) (ServerInfo, error) { } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return ServerInfo{}, err }