From 99dfcef67b33317298a696722af1b584445a09b3 Mon Sep 17 00:00:00 2001 From: Keannu Bernasol Date: Fri, 10 May 2024 23:15:29 +0800 Subject: [PATCH] Overhauled entire project config, added notifications, email templates, optimized stripe subscriptions, redis caching, and webdriver utilities --- .dockerignore | 4 + .env.sample | 60 + .gitignore | 13 +- .prettierignore | 1 + Dockerfile | 45 +- Pipfile | 17 + Pipfile.lock | 1468 +++++++++++------ README.md | 18 + backend/.env.sample | 21 - backend/accounts/admin.py | 2 +- backend/accounts/apps.py | 3 + backend/accounts/migrations/0001_initial.py | 96 +- backend/accounts/models.py | 92 +- backend/accounts/serializers.py | 59 +- backend/accounts/signals.py | 103 ++ backend/accounts/tasks.py | 23 + backend/accounts/urls.py | 7 +- backend/accounts/validators.py | 44 + backend/accounts/views.py | 121 +- backend/api/urls.py | 13 +- backend/billing/__init__.py | 0 backend/billing/apps.py | 6 + backend/billing/migrations/__init__.py | 0 backend/billing/urls.py | 7 + backend/billing/views.py | 61 + backend/config/__init__.py | 3 + backend/config/celery.py | 17 + backend/config/settings.py | 266 ++- backend/config/urls.py | 4 + backend/emails/__init__.py | 0 backend/emails/apps.py | 6 + backend/emails/templates.py | 49 + backend/emails/templates/activation.html | 28 + backend/emails/templates/password_reset.html | 29 + .../templates/subscription_availed.html | 20 + .../templates/subscription_cancelled.html | 24 + .../templates/subscription_refunded.html | 20 + backend/notifications/__init__.py | 0 backend/notifications/admin.py | 10 + backend/notifications/apps.py | 9 + .../notifications/migrations/0001_initial.py | 27 + backend/notifications/migrations/__init__.py | 0 backend/notifications/models.py | 12 + backend/notifications/serializers.py | 12 + backend/notifications/signals.py | 13 + backend/notifications/tasks.py | 13 + backend/notifications/urls.py | 10 + backend/notifications/views.py | 35 + backend/payments/__init__.py | 0 backend/payments/apps.py | 6 + backend/payments/migrations/__init__.py | 0 backend/payments/serializers.py | 6 + backend/payments/urls.py | 8 + backend/payments/views.py | 398 +++++ backend/schema.yml | 405 ++++- backend/subscriptions/__init__.py | 0 backend/subscriptions/admin.py | 29 + backend/subscriptions/apps.py | 9 + .../subscriptions/migrations/0001_initial.py | 56 + backend/subscriptions/migrations/__init__.py | 0 backend/subscriptions/models.py | 56 + backend/subscriptions/serializers.py | 44 + backend/subscriptions/signals.py | 91 + backend/subscriptions/tasks.py | 42 + backend/subscriptions/urls.py | 14 + backend/subscriptions/views.py | 56 + backend/user_groups/__init__.py | 0 backend/user_groups/admin.py | 15 + backend/user_groups/apps.py | 9 + .../user_groups/migrations/0001_initial.py | 23 + ...agers_usergroup_members_usergroup_owner.py | 31 + backend/user_groups/migrations/__init__.py | 0 backend/user_groups/models.py | 24 + backend/user_groups/serializers.py | 12 + backend/user_groups/signals.py | 107 ++ backend/webdriver/__init__.py | 0 backend/webdriver/apps.py | 6 + backend/webdriver/tasks.py | 16 + backend/webdriver/utils.py | 390 +++++ docker-compose.yml | 115 +- documentation/erd/app_models.png | Bin 0 -> 151473 bytes requirements.txt | 175 +- seed_data.json | 97 ++ start.sh | 36 + 84 files changed, 4300 insertions(+), 867 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.sample create mode 100644 .prettierignore create mode 100644 README.md delete mode 100644 backend/.env.sample create mode 100644 backend/accounts/signals.py create mode 100644 backend/accounts/tasks.py create mode 100644 backend/accounts/validators.py create mode 100644 backend/billing/__init__.py create mode 100644 backend/billing/apps.py create mode 100644 backend/billing/migrations/__init__.py create mode 100644 backend/billing/urls.py create mode 100644 backend/billing/views.py create mode 100644 backend/config/celery.py create mode 100644 backend/emails/__init__.py create mode 100644 backend/emails/apps.py create mode 100644 backend/emails/templates.py create mode 100644 backend/emails/templates/activation.html create mode 100644 backend/emails/templates/password_reset.html create mode 100644 backend/emails/templates/subscription_availed.html create mode 100644 backend/emails/templates/subscription_cancelled.html create mode 100644 backend/emails/templates/subscription_refunded.html create mode 100644 backend/notifications/__init__.py create mode 100644 backend/notifications/admin.py create mode 100644 backend/notifications/apps.py create mode 100644 backend/notifications/migrations/0001_initial.py create mode 100644 backend/notifications/migrations/__init__.py create mode 100644 backend/notifications/models.py create mode 100644 backend/notifications/serializers.py create mode 100644 backend/notifications/signals.py create mode 100644 backend/notifications/tasks.py create mode 100644 backend/notifications/urls.py create mode 100644 backend/notifications/views.py create mode 100644 backend/payments/__init__.py create mode 100644 backend/payments/apps.py create mode 100644 backend/payments/migrations/__init__.py create mode 100644 backend/payments/serializers.py create mode 100644 backend/payments/urls.py create mode 100644 backend/payments/views.py create mode 100644 backend/subscriptions/__init__.py create mode 100644 backend/subscriptions/admin.py create mode 100644 backend/subscriptions/apps.py create mode 100644 backend/subscriptions/migrations/0001_initial.py create mode 100644 backend/subscriptions/migrations/__init__.py create mode 100644 backend/subscriptions/models.py create mode 100644 backend/subscriptions/serializers.py create mode 100644 backend/subscriptions/signals.py create mode 100644 backend/subscriptions/tasks.py create mode 100644 backend/subscriptions/urls.py create mode 100644 backend/subscriptions/views.py create mode 100644 backend/user_groups/__init__.py create mode 100644 backend/user_groups/admin.py create mode 100644 backend/user_groups/apps.py create mode 100644 backend/user_groups/migrations/0001_initial.py create mode 100644 backend/user_groups/migrations/0002_usergroup_managers_usergroup_members_usergroup_owner.py create mode 100644 backend/user_groups/migrations/__init__.py create mode 100644 backend/user_groups/models.py create mode 100644 backend/user_groups/serializers.py create mode 100644 backend/user_groups/signals.py create mode 100644 backend/webdriver/__init__.py create mode 100644 backend/webdriver/apps.py create mode 100644 backend/webdriver/tasks.py create mode 100644 backend/webdriver/utils.py create mode 100644 documentation/erd/app_models.png create mode 100644 seed_data.json create mode 100644 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d4a6e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +firefox/ +chrome/ +dumps/ +media/ \ No newline at end of file diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..ef58e7c --- /dev/null +++ b/.env.sample @@ -0,0 +1,60 @@ +# Django +### Use https://djecrety.ir/ for generation! +SECRET_KEY = '' + +# Production Switches +BACKEND_DEBUG = 'True' + +# Superuser Credentials +DJANGO_ADMIN_USERNAME = 'admin' +DJANGO_ADMIN_EMAIL = 'admin@drf-template.com' +DJANGO_ADMIN_PASSWORD = '' + +# Seed Data Credentials +SEED_DATA = 'True' +SEED_DATA_PASSWORD = '12345' + +# Email Credentials +EMAIL_HOST = 'inbucket' +EMAIL_HOST_USER = '' +EMAIL_HOST_PASSWORD = '' +EMAIL_PORT = '1025' +EMAIL_USE_TLS = 'False' +EMAIL_ADDRESS = 'noreply-testing@drf-template.com' + +# Database +### Have different credentials set on dev, staging, and prod! +DB_DATABASE = 'drf-template' +DB_USERNAME = 'root' +DB_PASSWORD = '' +DB_HOST = 'postgres' +DB_PORT = '5432' +DB_SSL_MODE = 'disable' + +# Redis +### Used for DB cache and Celery broker +REDIS_HOST = 'redis' +REDIS_PORT = '6379' + +# Celery +CELERY_BROKER = 'redis://redis:6379/0' +CELERY_RESULT_BACKEND = 'redis://redis:6379/0' + +# Stripe +STRIPE_SECRET_KEY = '' +STRIPE_SECRET_WEBHOOK = '' + +BACKEND_DOMAIN = 'localhost:8000' +DOMAIN = 'localhost:4200' +USE_HTTPS = 'False' +DJANGO_PORT = '8000' + +# Proxy (For Selenium) +USE_PROXY = 'False' +## IP-Whitelisted Proxy Address +PROXY_IP_WHITELIST = 'proxy-here.com:12345' +## Username/Password Proxy Address +PROXY_USER_AUTH = 'username:password@proxy-here.com:12345' + +# CAPTCHA +CAPTCHA_TESTING = 'True' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 59a050c..66951a9 100644 --- a/.gitignore +++ b/.gitignore @@ -61,15 +61,14 @@ cover/ # Django stuff: *.log -local_settings.py db.sqlite3 db.sqlite3-journal - -backend/db.sqlite3 -backend/.env -backend/static/* -backend/static/ -backend/media/ +.env +media/* +static/ +dumps/ +firefox/ +chrome/ # Flask stuff: instance/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0b84df0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.html \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3e2822f..110b7bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,41 @@ -ARG DOCKER_PLATFORM=$TARGETPLATFORM -FROM --platform=$DOCKER_PLATFORM python:3.11.4-bookworm +FROM python:3.11.4-bookworm ENV PYTHONBUFFERED 1 +ENV DEBIAN_FRONTEND noninteractive -# Create directory RUN mkdir /code - -# Set the working directory to /code WORKDIR /code - -# Mirror the current directory to the working directory for hotreloading +# Directory mirroring ADD . /code/ +COPY . /code/ +COPY start.sh /code/ +RUN chmod +x /code/start.sh -# Install pipenv -RUN pip install --no-cache-dir -r requirements.txt +# Install packages +RUN apt-get update && apt-get install -y graphviz libgraphviz-dev graphviz-dev wget zip +RUN pip3 install --upgrade pip +RUN pip3 install --no-cache-dir -r requirements.txt -# Make migrations -RUN python backend/manage.py makemigrations +# Install Chrome +ENV CHROMEDRIVER_VERSION=124.0.6367.155 +RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb +RUN apt-get install -y ./google-chrome-stable_current_amd64.deb -# Run custom migrate -RUN python backend/manage.py migrate - -# Generate DRF Spectacular Documentation -RUN python backend/manage.py spectacular --color --file backend/schema.yml +# Install Chromedriver +RUN wget https://storage.googleapis.com/chrome-for-testing-public/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip \ + && unzip chromedriver-linux64.zip && rm -dfr chromedriver_linux64.zip \ + && mv chromedriver-linux64/chromedriver /usr/bin/chromedriver \ + && chmod +x /usr/bin/chromedriver + +# Install Firefox and Geckodriver +RUN apt-get update && apt-get install -y firefox-esr +# Download the latest Geckodriver and install it +ENV GECKODRIVER_VERSION=latest +RUN wget -O geckodriver.tar.gz https://github.com/mozilla/geckodriver/releases/download/v0.34.0/geckodriver-v0.34.0-linux64.tar.gz +RUN tar -zxf geckodriver.tar.gz -C /usr/bin +RUN chmod +x /usr/bin/geckodriver # Expose port 8000 for the web server EXPOSE 8000 + +ENTRYPOINT [ "/code/start.sh" ] \ No newline at end of file diff --git a/Pipfile b/Pipfile index de09d75..6f41933 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,23 @@ psycopg2 = "*" django-simple-history = "*" django-unfold = "*" django-resized = "*" +stripe = "*" +celery = "*" +selenium = "*" +undetected-chromedriver = "*" +2captcha-python = "*" +python-whois = "*" +django-celery-beat = "*" +flower = "*" +kombu = "*" +redis = "*" +django-storages = "*" +django-extensions = "*" +django-celery-results = "*" +pygraphviz = "*" +gunicorn = "*" +django-silk = "*" +django-redis = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 4d6fe99..59e91c9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "76312f845fabe0018aeaa99f91c1f80e3825b1e241d419c2d0b8467519b47b2c" + "sha256": "f74736038a7d74d3476854c6bd7d74d235a586c4ba11bc0b450796af1daf76eb" }, "pipfile-spec": 6, "requires": { @@ -16,21 +16,37 @@ ] }, "default": { - "annotated-types": { + "2captcha-python": { "hashes": [ - "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", - "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d" + "sha256:026a2e756eb5d507606a053b61d0837967ee5af02111c76980814cc65ea86762", + "sha256:3a8efdfd2ec6b256a2e05628cc88ef7a9292f9ac66ea7bf6b76ecb74bb3d4db2" ], - "markers": "python_version >= '3.8'", - "version": "==0.6.0" + "index": "pypi", + "version": "==1.2.5" + }, + "amqp": { + "hashes": [ + "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637", + "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd" + ], + "markers": "python_version >= '3.6'", + "version": "==5.2.0" }, "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" + }, + "async-timeout": { + "hashes": [ + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" + ], + "markers": "python_full_version < '3.11.3'", + "version": "==4.0.3" }, "attrs": { "hashes": [ @@ -40,13 +56,51 @@ "markers": "python_version >= '3.7'", "version": "==23.2.0" }, + "autobahn": { + "hashes": [ + "sha256:ec9421c52a2103364d1ef0468036e6019ee84f71721e86b36fe19ad6966c1181" + ], + "markers": "python_version >= '3.9'", + "version": "==23.6.2" + }, + "automat": { + "hashes": [ + "sha256:c3164f8742b9dc440f3682482d32aaff7bb53f71740dd018533f9de286b64180", + "sha256:e56beb84edad19dcc11d30e8d9b895f75deeb5ef5e96b84a467066b3b84bb04e" + ], + "version": "==22.10.0" + }, + "autopep8": { + "hashes": [ + "sha256:1fa8964e4618929488f4ec36795c7ff12924a68b8bf01366c094fc52f770b6e7", + "sha256:2bb76888c5edbcafe6aabab3c47ba534f5a2c2d245c2eddced4a30c4b4946357" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.0" + }, + "billiard": { + "hashes": [ + "sha256:07aa978b308f334ff8282bd4a746e681b3513db5c9a514cbdd810cbbdc19714d", + "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c" + ], + "markers": "python_version >= '3.7'", + "version": "==4.2.0" + }, + "celery": { + "hashes": [ + "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64", + "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706" + ], + "index": "pypi", + "version": "==5.4.0" + }, "certifi": { "hashes": [ - "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", - "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" + "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f", + "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1" ], "markers": "python_version >= '3.6'", - "version": "==2023.11.17" + "version": "==2024.2.2" }, "cffi": { "hashes": [ @@ -103,7 +157,7 @@ "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" ], - "markers": "python_version >= '3.8'", + "markers": "os_name == 'nt' and implementation_name != 'pypy'", "version": "==1.16.0" }, "charset-normalizer": { @@ -210,6 +264,29 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "click-didyoumean": { + "hashes": [ + "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", + "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==0.3.1" + }, + "click-plugins": { + "hashes": [ + "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", + "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8" + ], + "version": "==1.1.1" + }, + "click-repl": { + "hashes": [ + "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", + "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812" + ], + "markers": "python_version >= '3.6'", + "version": "==0.3.0" + }, "colorama": { "hashes": [ "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", @@ -218,34 +295,65 @@ "markers": "platform_system == 'Windows'", "version": "==0.4.6" }, + "constantly": { + "hashes": [ + "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", + "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd" + ], + "markers": "python_version >= '3.8'", + "version": "==23.10.4" + }, + "cron-descriptor": { + "hashes": [ + "sha256:7b1a00d7d25d6ae6896c0da4457e790b98cba778398a3d48e341e5e0d33f0488", + "sha256:a67ba21804983b1427ed7f3e1ec27ee77bf24c652b0430239c268c5ddfbf9dc0" + ], + "version": "==1.4.3" + }, "cryptography": { "hashes": [ - "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960", - "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a", - "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc", - "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a", - "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf", - "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1", - "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39", - "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406", - "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a", - "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a", - "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c", - "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be", - "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15", - "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2", - "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d", - "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157", - "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003", - "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248", - "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a", - "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec", - "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309", - "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7", - "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d" + "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55", + "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785", + "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b", + "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886", + "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82", + "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1", + "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda", + "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f", + "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68", + "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60", + "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7", + "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd", + "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582", + "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc", + "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858", + "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b", + "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2", + "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678", + "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13", + "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4", + "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8", + "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604", + "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477", + "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e", + "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a", + "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9", + "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14", + "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda", + "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da", + "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562", + "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2", + "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9" ], "markers": "python_version >= '3.7'", - "version": "==41.0.7" + "version": "==42.0.7" + }, + "daphne": { + "hashes": [ + "sha256:618d1322bb4d875342b99dd2a10da2d9aae7ee3645f765965fdc1e658ea5290a", + "sha256:fcbcace38eb86624ae247c7ffdc8ac12f155d7d19eafac4247381896d6f33761" + ], + "version": "==4.1.2" }, "defusedxml": { "hashes": [ @@ -257,11 +365,26 @@ }, "django": { "hashes": [ - "sha256:8c8659665bc6e3a44fefe1ab0a291e5a3fb3979f9a8230be29de975e57e8f854", - "sha256:f47a37a90b9bbe2c8ec360235192c7fddfdc832206fcf618bb849b39256affc1" + "sha256:8363ac062bb4ef7c3f12d078f6fa5d154031d129a15170a1066412af49d30905", + "sha256:ff1b61005004e476e0aeea47c7f79b85864c70124030e95146315396f1e7951f" ], "index": "pypi", - "version": "==5.0.1" + "version": "==5.0.6" + }, + "django-celery-beat": { + "hashes": [ + "sha256:f75b2d129731f1214be8383e18fae6bfeacdb55dffb2116ce849222c0106f9ad" + ], + "index": "pypi", + "version": "==2.6.0" + }, + "django-celery-results": { + "hashes": [ + "sha256:0da4cd5ecc049333e4524a23fcfc3460dfae91aa0a60f1fae4b6b2889c254e01", + "sha256:3ecb7147f773f34d0381bac6246337ce4cf88a2ea7b82774ed48e518b67bb8fd" + ], + "index": "pypi", + "version": "==2.5.1" }, "django-cors-headers": { "hashes": [ @@ -271,6 +394,14 @@ "index": "pypi", "version": "==4.3.1" }, + "django-extensions": { + "hashes": [ + "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a", + "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401" + ], + "index": "pypi", + "version": "==3.2.3" + }, "django-extra-fields": { "hashes": [ "sha256:2334e914b346c0a19a7765bf0ff7895c46cf35d5f40315a68418f44b7ddbb33b" @@ -278,6 +409,14 @@ "index": "pypi", "version": "==3.0.2" }, + "django-redis": { + "hashes": [ + "sha256:6a02abaa34b0fea8bf9b707d2c363ab6adc7409950b2db93602e6cb292818c42", + "sha256:ebc88df7da810732e2af9987f7f426c96204bf89319df4c6da6ca9a2942edd5b" + ], + "index": "pypi", + "version": "==5.4.0" + }, "django-resized": { "hashes": [ "sha256:52d727860f64ef4fdadbe2e74b66231c71c59df4d95949e338fcd320450f77fa", @@ -286,13 +425,29 @@ "index": "pypi", "version": "==1.0.2" }, - "django-simple-history": { + "django-silk": { "hashes": [ - "sha256:19bd1a87e1e2eba34dfd43eab1fcf2da5752221f343232f2372b2121c7e3b97d", - "sha256:992dcca3cddc0b67b470fc91f77292e2d2a6010d37c9eac3536e9d80e8754032" + "sha256:34abb5852315f0f3303d45b7ab4a2caa9cf670102b614dbb2ac40a5d2d5cbffb", + "sha256:35a2051672b0be86af4ce734a0df0b6674c8c63f2df730b3756ec6e52923707d" ], "index": "pypi", - "version": "==3.4.0" + "version": "==5.1.0" + }, + "django-simple-history": { + "hashes": [ + "sha256:0030b3bb5ed219d6453320213e75cfa00dab7a381946124df9411729101c8874", + "sha256:eef2943d7c846270f11c8136f38a3ba00de2bebd7b1b89037de5d9b148ed677a" + ], + "index": "pypi", + "version": "==3.5.0" + }, + "django-storages": { + "hashes": [ + "sha256:31f263389e95ce3a1b902fb5f739a7ed32895f7d8b80179fe7453ecc0dfe102e", + "sha256:95a12836cd998d4c7a4512347322331c662d9114c4344f932f5e9c0fce000608" + ], + "index": "pypi", + "version": "==1.14.3" }, "django-templated-mail": { "hashes": [ @@ -301,21 +456,29 @@ ], "version": "==1.1.1" }, + "django-timezone-field": { + "hashes": [ + "sha256:0095f43da716552fcc606783cfb42cb025892514f1ec660ebfa96186eb83b74c", + "sha256:d40f7059d7bae4075725d04a9dae601af9fe3c7f0119a69b0e2c6194a782f797" + ], + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==6.1.0" + }, "django-unfold": { "hashes": [ - "sha256:38a3a784ffec90ac8e1177ffe88ae1b15080bf9d89493b5ee648623cbe02ce54", - "sha256:eb5edca8d83258e5491458c83ec46f3c5ae1a652740ccb6a6bf55bd6bc86fead" + "sha256:2739a256897660aa0dd768cd373dd38d178d653c674091c2dbe441d325522b8b", + "sha256:622995f6ec222d7e16c4ea5bde1f87508d25942e6bcf925ae7ead9f70b77405b" ], "index": "pypi", - "version": "==0.18.1" + "version": "==0.22.0" }, "djangorestframework": { "hashes": [ - "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", - "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" + "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6", + "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1" ], "index": "pypi", - "version": "==3.14.0" + "version": "==3.15.1" }, "djangorestframework-simplejwt": { "hashes": [ @@ -333,63 +496,85 @@ "index": "pypi", "version": "==2.2.2" }, - "dotty-dict": { - "hashes": [ - "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", - "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f" - ], - "markers": "python_version >= '3.5' and python_version < '4.0'", - "version": "==1.3.1" - }, "drf-spectacular": { "extras": [ "sidecar" ], "hashes": [ - "sha256:18d7ae74b2b5d533fd31f1c591ebaa5cce1447e0976ced927401e3163040dea9", - "sha256:6ab2d20674244e8c940c2883f744b43c34fc68c70ea3aefa802f574108c9699b" + "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981", + "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b" ], "index": "pypi", - "version": "==0.27.0" + "version": "==0.27.2" }, "drf-spectacular-sidecar": { "hashes": [ - "sha256:099ec58b6af6a90e851a9329b12a57aa1ee7daa6cef62fb504f2ed302f10da76", - "sha256:4b9e33b4dcfa43f84e3db2659d31766a018a2b98b02d8856d9cd69580a4911c9" + "sha256:089fdef46b520b7b1c8a497a398cde9336c3f20b115835baeb158dc4138d743d", + "sha256:1ecfbe86174461e3cf78a9cd49f69aa8d9e0710cb5e8b35107d3f8cc0f380c21" ], - "version": "==2024.1.1" + "version": "==2024.5.1" }, - "gitdb": { + "flower": { "hashes": [ - "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", - "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" + "sha256:5ab717b979530770c16afb48b50d2a98d23c3e9fe39851dcf6bc4d01845a02a0", + "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2" + ], + "index": "pypi", + "version": "==2.0.1" + }, + "gprof2dot": { + "hashes": [ + "sha256:45b4d298bd36608fccf9511c3fd88a773f7a1abc04d6cd39445b11ba43133ec5", + "sha256:f165b3851d3c52ee4915eb1bd6cca571e5759823c2cd0f71a79bda93c2dc85d6" + ], + "markers": "python_version >= '2.7'", + "version": "==2022.7.29" + }, + "gunicorn": { + "hashes": [ + "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", + "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63" + ], + "index": "pypi", + "version": "==22.0.0" + }, + "h11": { + "hashes": [ + "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", + "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" ], "markers": "python_version >= '3.7'", - "version": "==4.0.11" + "version": "==0.14.0" }, - "gitpython": { + "humanize": { "hashes": [ - "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4", - "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a" + "sha256:582a265c931c683a7e9b8ed9559089dea7edcf6cc95be39a3cbc2c5d5ac2bcfa", + "sha256:ce284a76d5b1377fd8836733b983bfb0b76f1aa1c090de2566fcf008d7f6ab16" ], - "markers": "python_version >= '3.7'", - "version": "==3.1.40" + "markers": "python_version >= '3.8'", + "version": "==4.9.0" + }, + "hyperlink": { + "hashes": [ + "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", + "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4" + ], + "version": "==21.0.0" }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" ], "markers": "python_version >= '3.5'", - "version": "==3.6" + "version": "==3.7" }, - "importlib-resources": { + "incremental": { "hashes": [ - "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a", - "sha256:e8bf90d8213b486f428c9c39714b920041cb02c184686a3dee24905aaa8105d6" + "sha256:912feeb5e0f7e0188e6f42241d2f450002e11bbc0937c65865045854c24c0bd0", + "sha256:b864a1f30885ee72c5ac2835a761b8fe8aa9c28b9395cacf27286602688d3e51" ], - "markers": "python_version >= '3.8'", - "version": "==6.1.1" + "version": "==22.10.0" }, "inflection": { "hashes": [ @@ -399,21 +584,13 @@ "markers": "python_version >= '3.5'", "version": "==0.5.1" }, - "jinja2": { - "hashes": [ - "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", - "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" - ], - "markers": "python_version >= '3.7'", - "version": "==3.1.2" - }, "jsonschema": { "hashes": [ - "sha256:4f614fd46d8d61258610998997743ec5492a648b33cf478c1ddc23ed4598a5fa", - "sha256:ed6231f0429ecf966f5bc8dfef245998220549cbbcf140f913b7464c52c3b6b3" + "sha256:5b22d434a45935119af990552c862e5d6d564e8f6601206b305a61fdf661a2b7", + "sha256:ff4cfd6b1367a40e7bc6411caec72effadd3db0bbe5017de188f2d6108335802" ], "markers": "python_version >= '3.8'", - "version": "==4.20.0" + "version": "==4.22.0" }, "jsonschema-specifications": { "hashes": [ @@ -423,87 +600,75 @@ "markers": "python_version >= '3.8'", "version": "==2023.12.1" }, - "markdown-it-py": { + "kombu": { "hashes": [ - "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", - "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + "sha256:011c4cd9a355c14a1de8d35d257314a1d2456d52b7140388561acac3cf1a97bf", + "sha256:5634c511926309c7f9789f1433e9ed402616b56836ef9878f01bd59267b4c7a9" + ], + "index": "pypi", + "version": "==5.3.7" + }, + "msgpack": { + "hashes": [ + "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982", + "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3", + "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40", + "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee", + "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693", + "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950", + "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151", + "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24", + "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305", + "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b", + "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c", + "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659", + "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d", + "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18", + "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746", + "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868", + "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2", + "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba", + "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228", + "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2", + "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273", + "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c", + "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653", + "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a", + "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596", + "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd", + "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8", + "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa", + "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85", + "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc", + "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836", + "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3", + "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58", + "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128", + "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db", + "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f", + "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77", + "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad", + "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13", + "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8", + "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b", + "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a", + "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543", + "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b", + "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce", + "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d", + "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a", + "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c", + "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f", + "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e", + "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011", + "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04", + "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480", + "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a", + "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d", + "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d" ], "markers": "python_version >= '3.8'", - "version": "==3.0.0" - }, - "markupsafe": { - "hashes": [ - "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", - "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", - "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", - "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", - "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c", - "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", - "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", - "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", - "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939", - "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", - "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", - "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", - "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", - "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", - "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", - "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", - "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd", - "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", - "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", - "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", - "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", - "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", - "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", - "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", - "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", - "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007", - "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", - "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", - "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", - "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", - "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", - "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", - "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", - "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1", - "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", - "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", - "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c", - "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", - "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823", - "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", - "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", - "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", - "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", - "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", - "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", - "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", - "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", - "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", - "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", - "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", - "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", - "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", - "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", - "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", - "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", - "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", - "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", - "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc", - "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2", - "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11" - ], - "markers": "python_version >= '3.7'", - "version": "==2.1.3" - }, - "mdurl": { - "hashes": [ - "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", - "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" - ], - "markers": "python_version >= '3.7'", - "version": "==0.1.2" + "version": "==1.0.8" }, "oauthlib": { "hashes": [ @@ -513,79 +678,112 @@ "markers": "python_version >= '3.6'", "version": "==3.2.2" }, + "outcome": { + "hashes": [ + "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", + "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.0.post0" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, "pillow": { "hashes": [ - "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8", - "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39", - "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac", - "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869", - "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e", - "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04", - "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9", - "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e", - "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe", - "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef", - "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56", - "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa", - "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f", - "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f", - "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e", - "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a", - "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2", - "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2", - "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5", - "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a", - "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2", - "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213", - "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563", - "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591", - "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c", - "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2", - "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb", - "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757", - "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0", - "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452", - "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad", - "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01", - "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f", - "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5", - "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61", - "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e", - "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b", - "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068", - "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9", - "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588", - "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483", - "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f", - "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67", - "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7", - "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311", - "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6", - "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72", - "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6", - "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129", - "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13", - "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67", - "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c", - "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516", - "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e", - "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e", - "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364", - "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023", - "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1", - "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04", - "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d", - "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a", - "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7", - "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb", - "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4", - "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e", - "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1", - "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48", - "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" + "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c", + "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2", + "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb", + "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d", + "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa", + "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3", + "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1", + "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a", + "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd", + "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8", + "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999", + "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599", + "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936", + "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375", + "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d", + "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b", + "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60", + "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572", + "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3", + "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced", + "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f", + "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b", + "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19", + "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f", + "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d", + "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383", + "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795", + "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355", + "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57", + "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09", + "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b", + "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462", + "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf", + "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f", + "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a", + "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad", + "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9", + "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d", + "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45", + "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994", + "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", + "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338", + "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463", + "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451", + "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591", + "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c", + "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd", + "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32", + "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9", + "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf", + "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5", + "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828", + "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3", + "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5", + "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2", + "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b", + "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2", + "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475", + "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3", + "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb", + "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef", + "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015", + "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002", + "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170", + "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84", + "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57", + "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f", + "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27", + "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a" ], "index": "pypi", - "version": "==10.2.0" + "version": "==10.3.0" + }, + "prometheus-client": { + "hashes": [ + "sha256:287629d00b147a32dcb2be0b9df905da599b2d82f80377083ec8463309a4bb89", + "sha256:cde524a85bce83ca359cc837f28b8c0db5cac7aa653a588fd7e84ba061c329e7" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.0" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d", + "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.0.43" }, "psycopg2": { "hashes": [ @@ -606,139 +804,44 @@ "index": "pypi", "version": "==2.9.9" }, + "pyasn1": { + "hashes": [ + "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c", + "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473" + ], + "markers": "python_version >= '3.8'", + "version": "==0.6.0" + }, + "pyasn1-modules": { + "hashes": [ + "sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6", + "sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b" + ], + "markers": "python_version >= '3.8'", + "version": "==0.4.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", + "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" + ], + "markers": "python_version >= '3.8'", + "version": "==2.11.1" + }, "pycparser": { "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", + "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" ], - "version": "==2.21" + "markers": "python_version >= '3.8'", + "version": "==2.22" }, - "pydantic": { + "pygraphviz": { "hashes": [ - "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a", - "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4" + "sha256:6ad8aa2f26768830a5a1cfc8a14f022d13df170a8f6fdfd68fd1aa1267000964" ], - "markers": "python_version >= '3.7'", - "version": "==2.5.3" - }, - "pydantic-core": { - "hashes": [ - "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556", - "sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e", - "sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411", - "sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245", - "sha256:095b707bb287bfd534044166ab767bec70a9bba3175dcdc3371782175c14e43c", - "sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66", - "sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd", - "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d", - "sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b", - "sha256:1ce830e480f6774608dedfd4a90c42aac4a7af0a711f1b52f807130c2e434c06", - "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948", - "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341", - "sha256:24368e31be2c88bd69340fbfe741b405302993242ccb476c5c3ff48aeee1afe0", - "sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f", - "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a", - "sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2", - "sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51", - "sha256:2dbe357bc4ddda078f79d2a36fc1dd0494a7f2fad83a0a684465b6f24b46fe80", - "sha256:2f5fa187bde8524b1e37ba894db13aadd64faa884657473b03a019f625cee9a8", - "sha256:2f6ffc6701a0eb28648c845f4945a194dc7ab3c651f535b81793251e1185ac3d", - "sha256:314ccc4264ce7d854941231cf71b592e30d8d368a71e50197c905874feacc8a8", - "sha256:36026d8f99c58d7044413e1b819a67ca0e0b8ebe0f25e775e6c3d1fabb3c38fb", - "sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590", - "sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87", - "sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534", - "sha256:3860c62057acd95cc84044e758e47b18dcd8871a328ebc8ccdefd18b0d26a21b", - "sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145", - "sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba", - "sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b", - "sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2", - "sha256:438027a975cc213a47c5d70672e0d29776082155cfae540c4e225716586be75e", - "sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052", - "sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622", - "sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab", - "sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b", - "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66", - "sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e", - "sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4", - "sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e", - "sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec", - "sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c", - "sha256:6af4b3f52cc65f8a0bc8b1cd9676f8c21ef3e9132f21fed250f6958bd7223bed", - "sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937", - "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f", - "sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9", - "sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4", - "sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96", - "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277", - "sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23", - "sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7", - "sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b", - "sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91", - "sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d", - "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e", - "sha256:7f5025db12fc6de7bc1104d826d5aee1d172f9ba6ca936bf6474c2148ac336c1", - "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2", - "sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160", - "sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9", - "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670", - "sha256:8ed1af8692bd8d2a29d702f1a2e6065416d76897d726e45a1775b1444f5928a7", - "sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c", - "sha256:94fc0e6621e07d1e91c44e016cc0b189b48db053061cc22d6298a611de8071bb", - "sha256:982487f8931067a32e72d40ab6b47b1628a9c5d344be7f1a4e668fb462d2da42", - "sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d", - "sha256:99b14dbea2fdb563d8b5a57c9badfcd72083f6006caf8e126b491519c7d64ca8", - "sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1", - "sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6", - "sha256:a306cdd2ad3a7d795d8e617a58c3a2ed0f76c8496fb7621b6cd514eb1532cae8", - "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf", - "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e", - "sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a", - "sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9", - "sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1", - "sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40", - "sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2", - "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d", - "sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f", - "sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f", - "sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af", - "sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7", - "sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda", - "sha256:d15687d7d7f40333bd8266f3814c591c2e2cd263fa2116e314f60d82086e353a", - "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95", - "sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0", - "sha256:dab03ed811ed1c71d700ed08bde8431cf429bbe59e423394f0f4055f1ca0ea60", - "sha256:db453f2da3f59a348f514cfbfeb042393b68720787bbef2b4c6068ea362c8149", - "sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975", - "sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4", - "sha256:dfcbebdb3c4b6f739a91769aea5ed615023f3c88cb70df812849aef634c25fbe", - "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94", - "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03", - "sha256:e33b0834f1cf779aa839975f9d8755a7c2420510c0fa1e9fa0497de77cd35d2c", - "sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b", - "sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a", - "sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24", - "sha256:e646c0e282e960345314f42f2cea5e0b5f56938c093541ea6dbf11aec2862391", - "sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c", - "sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab", - "sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd", - "sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786", - "sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08", - "sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8", - "sha256:f96ae96a060a8072ceff4cfde89d261837b4294a4f28b84a28765470d502ccc6", - "sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0", - "sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421" - ], - "markers": "python_version >= '3.7'", - "version": "==2.14.6" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" + "index": "pypi", + "version": "==1.13" }, "pyjwt": { "hashes": [ @@ -748,29 +851,51 @@ "markers": "python_version >= '3.7'", "version": "==2.8.0" }, + "pyopenssl": { + "hashes": [ + "sha256:17ed5be5936449c5418d1cd269a1a9e9081bc54c17aed272b45856a3d3dc86ad", + "sha256:cabed4bfaa5df9f1a16c0ef64a0cb65318b5cd077a7eda7d6970131ca2f41a6f" + ], + "version": "==24.1.0" + }, + "pysocks": { + "hashes": [ + "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", + "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", + "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" + ], + "version": "==1.7.1" + }, + "python-crontab": { + "hashes": [ + "sha256:6d5ba3c190ec76e4d252989a1644fcb233dbf53fbc8fceeb9febe1657b9fb1d4", + "sha256:79fb7465039ddfd4fb93d072d6ee0d45c1ac8bf1597f0686ea14fd4361dba379" + ], + "version": "==3.0.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.9.0.post0" + }, "python-dotenv": { "hashes": [ - "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", - "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" + "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", + "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a" ], "index": "pypi", - "version": "==1.0.0" + "version": "==1.0.1" }, - "python-gitlab": { + "python-whois": { "hashes": [ - "sha256:cc1dc49c562c02ffbad3656e668234c45ea6210688ade59865b284313f45000d", - "sha256:eb31d1f2bfd8653f74996f9d0bf84ce7afb0843f9122a257c9a93b0e027d1df0" + "sha256:17d60f25524fc811a1f6a66f0298c1e5534551d7a01a77dd5f6406426da734e1", + "sha256:77bc7347bf815d65ccd196711c29837652f07585aedad3c3c04dd885651fd7a7" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==4.3.0" - }, - "python-semantic-release": { - "hashes": [ - "sha256:6bbd11b1e8ac70e0946ed6d257094c851b2507edfbc393eef6093d0ed1dbe0b4", - "sha256:a016b1cf43a5f3667ce2cfddd8e30b6210a2d52b0e2f6b487aae1164f2540eaa" - ], - "markers": "python_version >= '3.7'", - "version": "==8.7.0" + "index": "pypi", + "version": "==0.9.4" }, "python3-openid": { "hashes": [ @@ -781,10 +906,10 @@ }, "pytz": { "hashes": [ - "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b", - "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7" + "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", + "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319" ], - "version": "==2023.3.post1" + "version": "==2024.1" }, "pyyaml": { "hashes": [ @@ -817,6 +942,7 @@ "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", @@ -842,13 +968,21 @@ "markers": "python_version >= '3.6'", "version": "==6.0.1" }, + "redis": { + "hashes": [ + "sha256:7adc2835c7a9b5033b7ad8f8918d09b7344188228809c98df07af226d39dec91", + "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61" + ], + "index": "pypi", + "version": "==5.0.4" + }, "referencing": { "hashes": [ - "sha256:3c57da0513e9563eb7e203ebe9bb3a1b509b042016433bd1e45a2853466c3dd3", - "sha256:7e4dc12271d8e15612bfe35792f5ea1c40970dadf8624602e33db2758f7ee554" + "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c", + "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de" ], "markers": "python_version >= '3.8'", - "version": "==0.32.1" + "version": "==0.35.1" }, "requests": { "hashes": [ @@ -860,196 +994,294 @@ }, "requests-oauthlib": { "hashes": [ - "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", - "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" + "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", + "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.3.1" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, - "rich": { - "hashes": [ - "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", - "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.0" + "markers": "python_version >= '3.4'", + "version": "==2.0.0" }, "rpds-py": { "hashes": [ - "sha256:0474df4ade9a3b4af96c3d36eb81856cb9462e4c6657d4caecfd840d2a13f3c9", - "sha256:071980663c273bf3d388fe5c794c547e6f35ba3335477072c713a3176bf14a60", - "sha256:07aab64e2808c3ebac2a44f67e9dc0543812b715126dfd6fe4264df527556cb6", - "sha256:088396c7c70e59872f67462fcac3ecbded5233385797021976a09ebd55961dfe", - "sha256:162d7cd9cd311c1b0ff1c55a024b8f38bd8aad1876b648821da08adc40e95734", - "sha256:19f00f57fdd38db4bb5ad09f9ead1b535332dbf624200e9029a45f1f35527ebb", - "sha256:1bdbc5fcb04a7309074de6b67fa9bc4b418ab3fc435fec1f2779a0eced688d04", - "sha256:1be2f033df1b8be8c3167ba3c29d5dca425592ee31e35eac52050623afba5772", - "sha256:24f7a2eb3866a9e91f4599851e0c8d39878a470044875c49bd528d2b9b88361c", - "sha256:290a81cfbe4673285cdf140ec5cd1658ffbf63ab359f2b352ebe172e7cfa5bf0", - "sha256:2946b120718eba9af2b4dd103affc1164a87b9e9ebff8c3e4c05d7b7a7e274e2", - "sha256:2bd82db36cd70b3628c0c57d81d2438e8dd4b7b32a6a9f25f24ab0e657cb6c4e", - "sha256:2ddef620e70eaffebed5932ce754d539c0930f676aae6212f8e16cd9743dd365", - "sha256:2e53b9b25cac9065328901713a7e9e3b12e4f57ef4280b370fbbf6fef2052eef", - "sha256:302bd4983bbd47063e452c38be66153760112f6d3635c7eeefc094299fa400a9", - "sha256:349cb40897fd529ca15317c22c0eab67f5ac5178b5bd2c6adc86172045210acc", - "sha256:358dafc89ce3894c7f486c615ba914609f38277ef67f566abc4c854d23b997fa", - "sha256:35953f4f2b3216421af86fd236b7c0c65935936a94ea83ddbd4904ba60757773", - "sha256:35ae5ece284cf36464eb160880018cf6088a9ac5ddc72292a6092b6ef3f4da53", - "sha256:3b811d182ad17ea294f2ec63c0621e7be92a1141e1012383461872cead87468f", - "sha256:3da5a4c56953bdbf6d04447c3410309616c54433146ccdb4a277b9cb499bc10e", - "sha256:3dc6a7620ba7639a3db6213da61312cb4aa9ac0ca6e00dc1cbbdc21c2aa6eb57", - "sha256:3f91df8e6dbb7360e176d1affd5fb0246d2b88d16aa5ebc7db94fd66b68b61da", - "sha256:4022b9dc620e14f30201a8a73898a873c8e910cb642bcd2f3411123bc527f6ac", - "sha256:413b9c17388bbd0d87a329d8e30c1a4c6e44e2bb25457f43725a8e6fe4161e9e", - "sha256:43d4dd5fb16eb3825742bad8339d454054261ab59fed2fbac84e1d84d5aae7ba", - "sha256:44627b6ca7308680a70766454db5249105fa6344853af6762eaad4158a2feebe", - "sha256:44a54e99a2b9693a37ebf245937fd6e9228b4cbd64b9cc961e1f3391ec6c7391", - "sha256:47713dc4fce213f5c74ca8a1f6a59b622fc1b90868deb8e8e4d993e421b4b39d", - "sha256:495a14b72bbe217f2695dcd9b5ab14d4f8066a00f5d209ed94f0aca307f85f6e", - "sha256:4c46ad6356e1561f2a54f08367d1d2e70a0a1bb2db2282d2c1972c1d38eafc3b", - "sha256:4d6a9f052e72d493efd92a77f861e45bab2f6be63e37fa8ecf0c6fd1a58fedb0", - "sha256:509b617ac787cd1149600e731db9274ebbef094503ca25158e6f23edaba1ca8f", - "sha256:5552f328eaef1a75ff129d4d0c437bf44e43f9436d3996e8eab623ea0f5fcf73", - "sha256:5a80e2f83391ad0808b4646732af2a7b67550b98f0cae056cb3b40622a83dbb3", - "sha256:5cf6af100ffb5c195beec11ffaa8cf8523057f123afa2944e6571d54da84cdc9", - "sha256:5e6caa3809e50690bd92fa490f5c38caa86082c8c3315aa438bce43786d5e90d", - "sha256:5ef00873303d678aaf8b0627e111fd434925ca01c657dbb2641410f1cdaef261", - "sha256:69ac7ea9897ec201ce68b48582f3eb34a3f9924488a5432a93f177bf76a82a7e", - "sha256:6a61226465bda9283686db8f17d02569a98e4b13c637be5a26d44aa1f1e361c2", - "sha256:6d904c5693e08bad240f16d79305edba78276be87061c872a4a15e2c301fa2c0", - "sha256:6dace7b26a13353e24613417ce2239491b40a6ad44e5776a18eaff7733488b44", - "sha256:6df15846ee3fb2e6397fe25d7ca6624af9f89587f3f259d177b556fed6bebe2c", - "sha256:703d95c75a72e902544fda08e965885525e297578317989fd15a6ce58414b41d", - "sha256:726ac36e8a3bb8daef2fd482534cabc5e17334052447008405daca7ca04a3108", - "sha256:781ef8bfc091b19960fc0142a23aedadafa826bc32b433fdfe6fd7f964d7ef44", - "sha256:80443fe2f7b3ea3934c5d75fb0e04a5dbb4a8e943e5ff2de0dec059202b70a8b", - "sha256:83640a5d7cd3bff694747d50436b8b541b5b9b9782b0c8c1688931d6ee1a1f2d", - "sha256:84c5a4d1f9dd7e2d2c44097fb09fffe728629bad31eb56caf97719e55575aa82", - "sha256:882ce6e25e585949c3d9f9abd29202367175e0aab3aba0c58c9abbb37d4982ff", - "sha256:888a97002e986eca10d8546e3c8b97da1d47ad8b69726dcfeb3e56348ebb28a3", - "sha256:8aad80645a011abae487d356e0ceb359f4938dfb6f7bcc410027ed7ae4f7bb8b", - "sha256:8cb6fe8ecdfffa0e711a75c931fb39f4ba382b4b3ccedeca43f18693864fe850", - "sha256:8d6b6937ae9eac6d6c0ca3c42774d89fa311f55adff3970fb364b34abde6ed3d", - "sha256:90123853fc8b1747f80b0d354be3d122b4365a93e50fc3aacc9fb4c2488845d6", - "sha256:96f957d6ab25a78b9e7fc9749d754b98eac825a112b4e666525ce89afcbd9ed5", - "sha256:981d135c7cdaf6cd8eadae1c950de43b976de8f09d8e800feed307140d3d6d00", - "sha256:9b32f742ce5b57201305f19c2ef7a184b52f6f9ba6871cc042c2a61f0d6b49b8", - "sha256:9f0350ef2fba5f34eb0c9000ea328e51b9572b403d2f7f3b19f24085f6f598e8", - "sha256:a297a4d08cc67c7466c873c78039d87840fb50d05473db0ec1b7b03d179bf322", - "sha256:a3d7e2ea25d3517c6d7e5a1cc3702cffa6bd18d9ef8d08d9af6717fc1c700eed", - "sha256:a4b682c5775d6a3d21e314c10124599976809455ee67020e8e72df1769b87bc3", - "sha256:a4ebb8b20bd09c5ce7884c8f0388801100f5e75e7f733b1b6613c713371feefc", - "sha256:a61f659665a39a4d17d699ab3593d7116d66e1e2e3f03ef3fb8f484e91908808", - "sha256:a9880b4656efe36ccad41edc66789e191e5ee19a1ea8811e0aed6f69851a82f4", - "sha256:ac08472f41ea77cd6a5dae36ae7d4ed3951d6602833af87532b556c1b4601d63", - "sha256:adc0c3d6fc6ae35fee3e4917628983f6ce630d513cbaad575b4517d47e81b4bb", - "sha256:af27423662f32d7501a00c5e7342f7dbd1e4a718aea7a239781357d15d437133", - "sha256:b2e75e17bd0bb66ee34a707da677e47c14ee51ccef78ed6a263a4cc965a072a1", - "sha256:b634c5ec0103c5cbebc24ebac4872b045cccb9456fc59efdcf6fe39775365bd2", - "sha256:b6f5549d6ed1da9bfe3631ca9483ae906f21410be2445b73443fa9f017601c6f", - "sha256:bd4b677d929cf1f6bac07ad76e0f2d5de367e6373351c01a9c0a39f6b21b4a8b", - "sha256:bf721ede3eb7b829e4a9b8142bd55db0bdc82902720548a703f7e601ee13bdc3", - "sha256:c647ca87fc0ebe808a41de912e9a1bfef9acb85257e5d63691364ac16b81c1f0", - "sha256:ca57468da2d9a660bcf8961637c85f2fbb2aa64d9bc3f9484e30c3f9f67b1dd7", - "sha256:cad0f59ee3dc35526039f4bc23642d52d5f6616b5f687d846bfc6d0d6d486db0", - "sha256:cc97f0640e91d7776530f06e6836c546c1c752a52de158720c4224c9e8053cad", - "sha256:ccd4e400309e1f34a5095bf9249d371f0fd60f8a3a5c4a791cad7b99ce1fd38d", - "sha256:cffa76b385dfe1e38527662a302b19ffb0e7f5cf7dd5e89186d2c94a22dd9d0c", - "sha256:d0dd7ed2f16df2e129496e7fbe59a34bc2d7fc8db443a606644d069eb69cbd45", - "sha256:d452817e0d9c749c431a1121d56a777bd7099b720b3d1c820f1725cb40928f58", - "sha256:d8dda2a806dfa4a9b795950c4f5cc56d6d6159f7d68080aedaff3bdc9b5032f5", - "sha256:dcbe1f8dd179e4d69b70b1f1d9bb6fd1e7e1bdc9c9aad345cdeb332e29d40748", - "sha256:e0441fb4fdd39a230477b2ca9be90868af64425bfe7b122b57e61e45737a653b", - "sha256:e04e56b4ca7a770593633556e8e9e46579d66ec2ada846b401252a2bdcf70a6d", - "sha256:e061de3b745fe611e23cd7318aec2c8b0e4153939c25c9202a5811ca911fd733", - "sha256:e93ec1b300acf89730cf27975ef574396bc04edecc358e9bd116fb387a123239", - "sha256:e9e557db6a177470316c82f023e5d571811c9a4422b5ea084c85da9aa3c035fc", - "sha256:eab36eae3f3e8e24b05748ec9acc66286662f5d25c52ad70cadab544e034536b", - "sha256:ec23fcad480e77ede06cf4127a25fc440f7489922e17fc058f426b5256ee0edb", - "sha256:ec2e1cf025b2c0f48ec17ff3e642661da7ee332d326f2e6619366ce8e221f018", - "sha256:ed99b4f7179d2111702020fd7d156e88acd533f5a7d3971353e568b6051d5c97", - "sha256:ee94cb58c0ba2c62ee108c2b7c9131b2c66a29e82746e8fa3aa1a1effbd3dcf1", - "sha256:f19afcfc0dd0dca35694df441e9b0f95bc231b512f51bded3c3d8ca32153ec19", - "sha256:f1b9d9260e06ea017feb7172976ab261e011c1dc2f8883c7c274f6b2aabfe01a", - "sha256:f28ac0e8e7242d140f99402a903a2c596ab71550272ae9247ad78f9a932b5698", - "sha256:f42e25c016927e2a6b1ce748112c3ab134261fc2ddc867e92d02006103e1b1b7", - "sha256:f4bd4578e44f26997e9e56c96dedc5f1af43cc9d16c4daa29c771a00b2a26851", - "sha256:f811771019f063bbd0aa7bb72c8a934bc13ebacb4672d712fc1639cfd314cccc" + "sha256:05f3d615099bd9b13ecf2fc9cf2d839ad3f20239c678f461c753e93755d629ee", + "sha256:06d218939e1bf2ca50e6b0ec700ffe755e5216a8230ab3e87c059ebb4ea06afc", + "sha256:07f2139741e5deb2c5154a7b9629bc5aa48c766b643c1a6750d16f865a82c5fc", + "sha256:08d74b184f9ab6289b87b19fe6a6d1a97fbfea84b8a3e745e87a5de3029bf944", + "sha256:0abeee75434e2ee2d142d650d1e54ac1f8b01e6e6abdde8ffd6eeac6e9c38e20", + "sha256:154bf5c93d79558b44e5b50cc354aa0459e518e83677791e6adb0b039b7aa6a7", + "sha256:17c6d2155e2423f7e79e3bb18151c686d40db42d8645e7977442170c360194d4", + "sha256:1805d5901779662d599d0e2e4159d8a82c0b05faa86ef9222bf974572286b2b6", + "sha256:19ba472b9606c36716062c023afa2484d1e4220548751bda14f725a7de17b4f6", + "sha256:19e515b78c3fc1039dd7da0a33c28c3154458f947f4dc198d3c72db2b6b5dc93", + "sha256:1d54f74f40b1f7aaa595a02ff42ef38ca654b1469bef7d52867da474243cc633", + "sha256:207c82978115baa1fd8d706d720b4a4d2b0913df1c78c85ba73fe6c5804505f0", + "sha256:2625f03b105328729f9450c8badda34d5243231eef6535f80064d57035738360", + "sha256:27bba383e8c5231cd559affe169ca0b96ec78d39909ffd817f28b166d7ddd4d8", + "sha256:2c3caec4ec5cd1d18e5dd6ae5194d24ed12785212a90b37f5f7f06b8bedd7139", + "sha256:2cc7c1a47f3a63282ab0f422d90ddac4aa3034e39fc66a559ab93041e6505da7", + "sha256:2fc24a329a717f9e2448f8cd1f960f9dac4e45b6224d60734edeb67499bab03a", + "sha256:312fe69b4fe1ffbe76520a7676b1e5ac06ddf7826d764cc10265c3b53f96dbe9", + "sha256:32b7daaa3e9389db3695964ce8e566e3413b0c43e3394c05e4b243a4cd7bef26", + "sha256:338dee44b0cef8b70fd2ef54b4e09bb1b97fc6c3a58fea5db6cc083fd9fc2724", + "sha256:352a88dc7892f1da66b6027af06a2e7e5d53fe05924cc2cfc56495b586a10b72", + "sha256:35b2b771b13eee8729a5049c976197ff58a27a3829c018a04341bcf1ae409b2b", + "sha256:38e14fb4e370885c4ecd734f093a2225ee52dc384b86fa55fe3f74638b2cfb09", + "sha256:3c20f05e8e3d4fc76875fc9cb8cf24b90a63f5a1b4c5b9273f0e8225e169b100", + "sha256:3dd3cd86e1db5aadd334e011eba4e29d37a104b403e8ca24dcd6703c68ca55b3", + "sha256:489bdfe1abd0406eba6b3bb4fdc87c7fa40f1031de073d0cfb744634cc8fa261", + "sha256:48c2faaa8adfacefcbfdb5f2e2e7bdad081e5ace8d182e5f4ade971f128e6bb3", + "sha256:4a98a1f0552b5f227a3d6422dbd61bc6f30db170939bd87ed14f3c339aa6c7c9", + "sha256:4adec039b8e2928983f885c53b7cc4cda8965b62b6596501a0308d2703f8af1b", + "sha256:4e0ee01ad8260184db21468a6e1c37afa0529acc12c3a697ee498d3c2c4dcaf3", + "sha256:51584acc5916212e1bf45edd17f3a6b05fe0cbb40482d25e619f824dccb679de", + "sha256:531796fb842b53f2695e94dc338929e9f9dbf473b64710c28af5a160b2a8927d", + "sha256:5463c47c08630007dc0fe99fb480ea4f34a89712410592380425a9b4e1611d8e", + "sha256:5c45a639e93a0c5d4b788b2613bd637468edd62f8f95ebc6fcc303d58ab3f0a8", + "sha256:6031b25fb1b06327b43d841f33842b383beba399884f8228a6bb3df3088485ff", + "sha256:607345bd5912aacc0c5a63d45a1f73fef29e697884f7e861094e443187c02be5", + "sha256:618916f5535784960f3ecf8111581f4ad31d347c3de66d02e728de460a46303c", + "sha256:636a15acc588f70fda1661234761f9ed9ad79ebed3f2125d44be0862708b666e", + "sha256:673fdbbf668dd958eff750e500495ef3f611e2ecc209464f661bc82e9838991e", + "sha256:6afd80f6c79893cfc0574956f78a0add8c76e3696f2d6a15bca2c66c415cf2d4", + "sha256:6b5ff7e1d63a8281654b5e2896d7f08799378e594f09cf3674e832ecaf396ce8", + "sha256:6c4c4c3f878df21faf5fac86eda32671c27889e13570645a9eea0a1abdd50922", + "sha256:6cd8098517c64a85e790657e7b1e509b9fe07487fd358e19431cb120f7d96338", + "sha256:6d1e42d2735d437e7e80bab4d78eb2e459af48c0a46e686ea35f690b93db792d", + "sha256:6e30ac5e329098903262dc5bdd7e2086e0256aa762cc8b744f9e7bf2a427d3f8", + "sha256:70a838f7754483bcdc830444952fd89645569e7452e3226de4a613a4c1793fb2", + "sha256:720edcb916df872d80f80a1cc5ea9058300b97721efda8651efcd938a9c70a72", + "sha256:732672fbc449bab754e0b15356c077cc31566df874964d4801ab14f71951ea80", + "sha256:740884bc62a5e2bbb31e584f5d23b32320fd75d79f916f15a788d527a5e83644", + "sha256:7700936ef9d006b7ef605dc53aa364da2de5a3aa65516a1f3ce73bf82ecfc7ae", + "sha256:7732770412bab81c5a9f6d20aeb60ae943a9b36dcd990d876a773526468e7163", + "sha256:7750569d9526199c5b97e5a9f8d96a13300950d910cf04a861d96f4273d5b104", + "sha256:7f1944ce16401aad1e3f7d312247b3d5de7981f634dc9dfe90da72b87d37887d", + "sha256:81c5196a790032e0fc2464c0b4ab95f8610f96f1f2fa3d4deacce6a79852da60", + "sha256:8352f48d511de5f973e4f2f9412736d7dea76c69faa6d36bcf885b50c758ab9a", + "sha256:8927638a4d4137a289e41d0fd631551e89fa346d6dbcfc31ad627557d03ceb6d", + "sha256:8c7672e9fba7425f79019db9945b16e308ed8bc89348c23d955c8c0540da0a07", + "sha256:8d2e182c9ee01135e11e9676e9a62dfad791a7a467738f06726872374a83db49", + "sha256:910e71711d1055b2768181efa0a17537b2622afeb0424116619817007f8a2b10", + "sha256:942695a206a58d2575033ff1e42b12b2aece98d6003c6bc739fbf33d1773b12f", + "sha256:9437ca26784120a279f3137ee080b0e717012c42921eb07861b412340f85bae2", + "sha256:967342e045564cef76dfcf1edb700b1e20838d83b1aa02ab313e6a497cf923b8", + "sha256:998125738de0158f088aef3cb264a34251908dd2e5d9966774fdab7402edfab7", + "sha256:9e6934d70dc50f9f8ea47081ceafdec09245fd9f6032669c3b45705dea096b88", + "sha256:a3d456ff2a6a4d2adcdf3c1c960a36f4fd2fec6e3b4902a42a384d17cf4e7a65", + "sha256:a7b28c5b066bca9a4eb4e2f2663012debe680f097979d880657f00e1c30875a0", + "sha256:a888e8bdb45916234b99da2d859566f1e8a1d2275a801bb8e4a9644e3c7e7909", + "sha256:aa3679e751408d75a0b4d8d26d6647b6d9326f5e35c00a7ccd82b78ef64f65f8", + "sha256:aaa71ee43a703c321906813bb252f69524f02aa05bf4eec85f0c41d5d62d0f4c", + "sha256:b646bf655b135ccf4522ed43d6902af37d3f5dbcf0da66c769a2b3938b9d8184", + "sha256:b906b5f58892813e5ba5c6056d6a5ad08f358ba49f046d910ad992196ea61397", + "sha256:b9bb1f182a97880f6078283b3505a707057c42bf55d8fca604f70dedfdc0772a", + "sha256:bd1105b50ede37461c1d51b9698c4f4be6e13e69a908ab7751e3807985fc0346", + "sha256:bf18932d0003c8c4d51a39f244231986ab23ee057d235a12b2684ea26a353590", + "sha256:c273e795e7a0f1fddd46e1e3cb8be15634c29ae8ff31c196debb620e1edb9333", + "sha256:c69882964516dc143083d3795cb508e806b09fc3800fd0d4cddc1df6c36e76bb", + "sha256:c827576e2fa017a081346dce87d532a5310241648eb3700af9a571a6e9fc7e74", + "sha256:cbfbea39ba64f5e53ae2915de36f130588bba71245b418060ec3330ebf85678e", + "sha256:ce0bb20e3a11bd04461324a6a798af34d503f8d6f1aa3d2aa8901ceaf039176d", + "sha256:d0cee71bc618cd93716f3c1bf56653740d2d13ddbd47673efa8bf41435a60daa", + "sha256:d21be4770ff4e08698e1e8e0bce06edb6ea0626e7c8f560bc08222880aca6a6f", + "sha256:d31dea506d718693b6b2cffc0648a8929bdc51c70a311b2770f09611caa10d53", + "sha256:d44607f98caa2961bab4fa3c4309724b185b464cdc3ba6f3d7340bac3ec97cc1", + "sha256:d58ad6317d188c43750cb76e9deacf6051d0f884d87dc6518e0280438648a9ac", + "sha256:d70129cef4a8d979caa37e7fe957202e7eee8ea02c5e16455bc9808a59c6b2f0", + "sha256:d85164315bd68c0806768dc6bb0429c6f95c354f87485ee3593c4f6b14def2bd", + "sha256:d960de62227635d2e61068f42a6cb6aae91a7fe00fca0e3aeed17667c8a34611", + "sha256:dc48b479d540770c811fbd1eb9ba2bb66951863e448efec2e2c102625328e92f", + "sha256:e1735502458621921cee039c47318cb90b51d532c2766593be6207eec53e5c4c", + "sha256:e2be6e9dd4111d5b31ba3b74d17da54a8319d8168890fbaea4b9e5c3de630ae5", + "sha256:e4c39ad2f512b4041343ea3c7894339e4ca7839ac38ca83d68a832fc8b3748ab", + "sha256:ed402d6153c5d519a0faf1bb69898e97fb31613b49da27a84a13935ea9164dfc", + "sha256:ee17cd26b97d537af8f33635ef38be873073d516fd425e80559f4585a7b90c43", + "sha256:f3027be483868c99b4985fda802a57a67fdf30c5d9a50338d9db646d590198da", + "sha256:f5bab211605d91db0e2995a17b5c6ee5edec1270e46223e513eaa20da20076ac", + "sha256:f6f8e3fecca256fefc91bb6765a693d96692459d7d4c644660a9fff32e517843", + "sha256:f7afbfee1157e0f9376c00bb232e80a60e59ed716e3211a80cb8506550671e6e", + "sha256:fa242ac1ff583e4ec7771141606aafc92b361cd90a05c30d93e343a0c2d82a89", + "sha256:fab6ce90574645a0d6c58890e9bcaac8d94dff54fb51c69e5522a7358b80ab64" ], "markers": "python_version >= '3.8'", - "version": "==0.16.2" + "version": "==0.18.1" }, - "shellingham": { + "selenium": { "hashes": [ - "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", - "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" + "sha256:0bd564ee166980d419a8aaf4ac00289bc152afcf2eadca5efe8c8e36711853fd", + "sha256:b1d0c33b38ca27d0499183e48e1dd09ff26973481f5d3ef2983073813ae6588d" + ], + "index": "pypi", + "version": "==4.20.0" + }, + "service-identity": { + "hashes": [ + "sha256:6829c9d62fb832c2e1c435629b0a8c476e1929881f28bee4d20bc24161009221", + "sha256:a28caf8130c8a5c1c7a6f5293faaf239bbfb7751e4862436920ee6f2616f568a" + ], + "version": "==24.1.0" + }, + "setuptools": { + "hashes": [ + "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", + "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" + ], + "markers": "python_version >= '3.8'", + "version": "==69.5.1" + }, + "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" + }, + "sniffio": { + "hashes": [ + "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", + "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" ], "markers": "python_version >= '3.7'", - "version": "==1.5.4" - }, - "smmap": { - "hashes": [ - "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", - "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" - ], - "markers": "python_version >= '3.7'", - "version": "==5.0.1" + "version": "==1.3.1" }, "social-auth-app-django": { "hashes": [ - "sha256:09ac02a063cb313eed5e9ef2f9ac4477c8bf5bbd685925ff3aba43f9072f1bbb", - "sha256:28c65b2e2092f30cdb3cf912eeaa6988b49fdf4001b29bd89e683673d700a38e" + "sha256:2a43cde559dd34fdc7132417b6c52c780fa99ec2332dee9f405b4763f371c367", + "sha256:7519f186c63c50f2d364457b236f051338d194bcface55e318a6a705c5213477" ], "markers": "python_version >= '3.8'", - "version": "==5.4.0" + "version": "==5.4.1" }, "social-auth-core": { "hashes": [ - "sha256:307a4ba64d4f3ec86e4389163eac1d8b8656ffe5ab2e964aeff043ab00b3a662", - "sha256:54d0c598bf6ea0ec12bbcf78bee035c7cd604b5d781d80b7997e9e033c3ac05d" + "sha256:33cf970a623c442376f9d4a86fb187579e4438649daa5b5be993d05e74d7b2db", + "sha256:d3dbeb0999ffd0e68aa4bd73f2ac698a18133fd11b3fc890e1366f18c8889fac" ], "markers": "python_version >= '3.8'", - "version": "==4.5.1" + "version": "==4.5.4" + }, + "sortedcontainers": { + "hashes": [ + "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", + "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" + ], + "version": "==2.4.0" }, "sqlparse": { "hashes": [ - "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", - "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" + "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", + "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" ], - "markers": "python_version >= '3.5'", - "version": "==0.4.4" + "markers": "python_version >= '3.8'", + "version": "==0.5.0" }, - "tomlkit": { + "stripe": { "hashes": [ - "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4", - "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba" + "sha256:9305d849cea715dc59c5e39d01891475b82e10edb9d95ee1d8189457e5de792f", + "sha256:f519f6810ac7f6e096b4faf562c44b1f8e365138441548e4ab0bc93f86368ad7" + ], + "index": "pypi", + "version": "==9.6.0" + }, + "tornado": { + "hashes": [ + "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0", + "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63", + "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263", + "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052", + "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f", + "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee", + "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78", + "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579", + "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212", + "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e", + "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2" + ], + "markers": "python_version >= '3.8'", + "version": "==6.4" + }, + "trio": { + "hashes": [ + "sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e", + "sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81" + ], + "markers": "python_version >= '3.8'", + "version": "==0.25.0" + }, + "trio-websocket": { + "hashes": [ + "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f", + "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638" ], "markers": "python_version >= '3.7'", - "version": "==0.12.3" + "version": "==0.11.1" + }, + "twisted": { + "extras": [ + "tls" + ], + "hashes": [ + "sha256:039f2e6a49ab5108abd94de187fa92377abe5985c7a72d68d0ad266ba19eae63", + "sha256:6b38b6ece7296b5e122c9eb17da2eeab3d98a198f50ca9efd00fb03e5b4fd4ae" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==24.3.0" + }, + "twisted-iocpsupport": { + "hashes": [ + "sha256:0058c963c8957bcd3deda62122e89953c9de1e867a274facc9b15dde1a9f31e8", + "sha256:0c1b5cf37f0b2d96cc3c9bc86fff16613b9f5d0ca565c96cf1f1fb8cfca4b81c", + "sha256:196f7c7ccad4ba4d1783b1c4e1d1b22d93c04275cd780bf7498d16c77319ad6e", + "sha256:300437af17396a945a58dcfffd77863303a8b6d9e65c6e81f1d2eed55b50d444", + "sha256:391ac4d6002a80e15f35adc4ad6056f4fe1c17ceb0d1f98ba01b0f4f917adfd7", + "sha256:3c5dc11d72519e55f727320e3cee535feedfaee09c0f0765ed1ca7badff1ab3c", + "sha256:3d306fc4d88a6bcf61ce9d572c738b918578121bfd72891625fab314549024b5", + "sha256:4574eef1f3bb81501fb02f911298af3c02fe8179c31a33b361dd49180c3e644d", + "sha256:4e5f97bcbabdd79cbaa969b63439b89801ea560f11d42b0a387634275c633623", + "sha256:6081bd7c2f4fcf9b383dcdb3b3385d75a26a7c9d2be25b6950c3d8ea652d2d2d", + "sha256:76f7e67cec1f1d097d1f4ed7de41be3d74546e1a4ede0c7d56e775c4dce5dfb0", + "sha256:7c66fa0aa4236b27b3c61cb488662d85dae746a6d1c7b0d91cf7aae118445adf", + "sha256:858096c0d15e33f15ac157f455d8f86f2f2cdd223963e58c0f682a3af8362d89", + "sha256:872747a3b64e2909aee59c803ccd0bceb9b75bf27915520ebd32d69687040fa2", + "sha256:afa2b630797f9ed2f27f3d9f55e3f72b4244911e45a8c82756f44babbf0b243e", + "sha256:c2712b778bacf1db434e3e065adfed3db300754186a29aecac1efae9ef4bcaff", + "sha256:c27985e949b9b1a1fb4c20c71d315c10ea0f93fdf3ccdd4a8c158b5926edd8c8", + "sha256:cc86c2ef598c15d824a243c2541c29459881c67fc3c0adb6efe2242f8f0ec3af", + "sha256:e311dfcb470696e3c077249615893cada598e62fa7c4e4ca090167bd2b7d331f" + ], + "markers": "platform_system == 'Windows'", + "version": "==1.0.4" + }, + "txaio": { + "hashes": [ + "sha256:aaea42f8aad50e0ecfb976130ada140797e9dcb85fad2cf72b0f37f8cefcb490", + "sha256:f9a9216e976e5e3246dfd112ad7ad55ca915606b60b84a757ac769bd404ff704" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1.1" }, "typing-extensions": { "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.9.0" + "version": "==4.11.0" }, "tzdata": { "hashes": [ - "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3", - "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9" + "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", + "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252" ], - "markers": "sys_platform == 'win32'", - "version": "==2023.4" + "markers": "python_version >= '2'", + "version": "==2024.1" + }, + "undetected-chromedriver": { + "hashes": [ + "sha256:9f945e1435005247abe17de316bcfda85b284a4177fd5f25167c78ced33b65ec" + ], + "index": "pypi", + "version": "==3.5.5" }, "uritemplate": { "hashes": [ @@ -1060,12 +1292,108 @@ "version": "==4.1.1" }, "urllib3": { + "extras": [ + "socks" + ], "hashes": [ - "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", - "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" + "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", + "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], "markers": "python_version >= '3.8'", - "version": "==2.1.0" + "version": "==2.2.1" + }, + "vine": { + "hashes": [ + "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", + "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0" + ], + "markers": "python_version >= '3.6'", + "version": "==5.1.0" + }, + "wcwidth": { + "hashes": [ + "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", + "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" + ], + "version": "==0.2.13" + }, + "websockets": { + "hashes": [ + "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b", + "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6", + "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df", + "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b", + "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205", + "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892", + "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53", + "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2", + "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed", + "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c", + "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd", + "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b", + "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931", + "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30", + "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370", + "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be", + "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec", + "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf", + "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62", + "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b", + "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402", + "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f", + "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123", + "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9", + "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603", + "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45", + "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558", + "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4", + "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438", + "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137", + "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480", + "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447", + "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8", + "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04", + "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c", + "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb", + "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967", + "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b", + "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d", + "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def", + "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c", + "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92", + "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2", + "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113", + "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b", + "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28", + "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7", + "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d", + "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f", + "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468", + "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8", + "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae", + "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611", + "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d", + "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9", + "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca", + "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f", + "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2", + "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077", + "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2", + "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6", + "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374", + "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc", + "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e", + "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53", + "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399", + "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547", + "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3", + "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870", + "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5", + "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8", + "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7" + ], + "markers": "python_version >= '3.8'", + "version": "==12.0" }, "whitenoise": { "hashes": [ @@ -1074,6 +1402,56 @@ ], "index": "pypi", "version": "==6.6.0" + }, + "wsproto": { + "hashes": [ + "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", + "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==1.2.0" + }, + "zope-interface": { + "hashes": [ + "sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e", + "sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf", + "sha256:10cde8dc6b2fd6a1d0b5ca4be820063e46ddba417ab82bcf55afe2227337b130", + "sha256:187f7900b63845dcdef1be320a523dbbdba94d89cae570edc2781eb55f8c2f86", + "sha256:1b0c4c90e5eefca2c3e045d9f9ed9f1e2cdbe70eb906bff6b247e17119ad89a1", + "sha256:22e8a218e8e2d87d4d9342aa973b7915297a08efbebea5b25900c73e78ed468e", + "sha256:26c9a37fb395a703e39b11b00b9e921c48f82b6e32cc5851ad5d0618cd8876b5", + "sha256:2bb78c12c1ad3a20c0d981a043d133299117b6854f2e14893b156979ed4e1d2c", + "sha256:2c3cfb272bcb83650e6695d49ae0d14dd06dc694789a3d929f23758557a23d92", + "sha256:2f32010ffb87759c6a3ad1c65ed4d2e38e51f6b430a1ca11cee901ec2b42e021", + "sha256:3c8731596198198746f7ce2a4487a0edcbc9ea5e5918f0ab23c4859bce56055c", + "sha256:40aa8c8e964d47d713b226c5baf5f13cdf3a3169c7a2653163b17ff2e2334d10", + "sha256:4137025731e824eee8d263b20682b28a0bdc0508de9c11d6c6be54163e5b7c83", + "sha256:46034be614d1f75f06e7dcfefba21d609b16b38c21fc912b01a99cb29e58febb", + "sha256:483e118b1e075f1819b3c6ace082b9d7d3a6a5eb14b2b375f1b80a0868117920", + "sha256:4d6b229f5e1a6375f206455cc0a63a8e502ed190fe7eb15e94a312dc69d40299", + "sha256:567d54c06306f9c5b6826190628d66753b9f2b0422f4c02d7c6d2b97ebf0a24e", + "sha256:5683aa8f2639016fd2b421df44301f10820e28a9b96382a6e438e5c6427253af", + "sha256:600101f43a7582d5b9504a7c629a1185a849ce65e60fca0f6968dfc4b76b6d39", + "sha256:62e32f02b3f26204d9c02c3539c802afc3eefb19d601a0987836ed126efb1f21", + "sha256:69dedb790530c7ca5345899a1b4cb837cc53ba669051ea51e8c18f82f9389061", + "sha256:72d5efecad16c619a97744a4f0b67ce1bcc88115aa82fcf1dc5be9bb403bcc0b", + "sha256:8d407e0fd8015f6d5dfad481309638e1968d70e6644e0753f229154667dd6cd5", + "sha256:a058e6cf8d68a5a19cb5449f42a404f0d6c2778b897e6ce8fadda9cea308b1b0", + "sha256:a1adc14a2a9d5e95f76df625a9b39f4709267a483962a572e3f3001ef90ea6e6", + "sha256:a56fe1261230093bfeedc1c1a6cd6f3ec568f9b07f031c9a09f46b201f793a85", + "sha256:ad4524289d8dbd6fb5aa17aedb18f5643e7d48358f42c007a5ee51a2afc2a7c5", + "sha256:afa0491a9f154cf8519a02026dc85a416192f4cb1efbbf32db4a173ba28b289a", + "sha256:bf34840e102d1d0b2d39b1465918d90b312b1119552cebb61a242c42079817b9", + "sha256:c40df4aea777be321b7e68facb901bc67317e94b65d9ab20fb96e0eb3c0b60a1", + "sha256:d0e7321557c702bd92dac3c66a2f22b963155fdb4600133b6b29597f62b71b12", + "sha256:d165d7774d558ea971cb867739fb334faf68fc4756a784e689e11efa3becd59e", + "sha256:e78a183a3c2f555c2ad6aaa1ab572d1c435ba42f1dc3a7e8c82982306a19b785", + "sha256:e8fa0fb05083a1a4216b4b881fdefa71c5d9a106e9b094cd4399af6b52873e91", + "sha256:f83d6b4b22262d9a826c3bd4b2fbfafe1d0000f085ef8e44cd1328eea274ae6a", + "sha256:f95bebd0afe86b2adc074df29edb6848fc4d474ff24075e2c263d698774e108d" + ], + "markers": "python_version >= '3.7'", + "version": "==6.3" } }, "develop": {} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f04b34 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +## DRF-Template + +This is a Django batteries-included template I personally use for my projects. This covers the following + +- Emails (and templated email designs) +- Celery (For asynchronous tasks) +- Celery Beat (For scheduled tasks) +- Caching (via Redis or optionally, Memcached) +- Performance profiling (via Django Silk) +- Selenium (Optional, for webscraping with support for Chrome and Firefox drivers) +- Stripe Subscriptions (Optional, with regular and pro-rated subscription support) +- Notifications (via traditional RESTful endpoints) + +## Development + +- Create a copy of the `.env.sample` file and name it as `.env` in the same directory +- Populate .env with values +- Run `docker-compose up` diff --git a/backend/.env.sample b/backend/.env.sample deleted file mode 100644 index 9a5e266..0000000 --- a/backend/.env.sample +++ /dev/null @@ -1,21 +0,0 @@ -# Django -SECRET_KEY = "" - -# Superuser Credentials -DJANGO_ADMIN_USERNAME = "" -DJANGO_ADMIN_EMAIL = "" -DJANGO_ADMIN_PASSWORD = "" - -# Production Email Credentials -PROD_EMAIL_HOST = "" -PROD_EMAIL_HOST_USER = "" -PROD_EMAIL_HOST_PASSWORD = "" -PROD_EMAIL_PORT = "" -PROD_EMAIL_TLS = "" - - -# Dev Email Credentials -DEV_EMAIL_HOST = "" -DEV_EMAIL_HOST_USER = "" -DEV_EMAIL_HOST_PASSWORD = "" -DEV_EMAIL_PORT = "" diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py index fcc6fd2..1909611 100644 --- a/backend/accounts/admin.py +++ b/backend/accounts/admin.py @@ -6,7 +6,7 @@ from .models import CustomUser class CustomUserAdmin(UserAdmin): model = CustomUser - list_display = ('id',) + UserAdmin.list_display + list_display = ('id', 'is_active', 'user_group',) + UserAdmin.list_display # Editable fields per instance fieldsets = UserAdmin.fieldsets + ( (None, {'fields': ('avatar',)}), diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py index 3e3c765..1bc78fc 100644 --- a/backend/accounts/apps.py +++ b/backend/accounts/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'accounts' + + def ready(self): + import accounts.signals diff --git a/backend/accounts/migrations/0001_initial.py b/backend/accounts/migrations/0001_initial.py index 189e3d5..f1205fc 100644 --- a/backend/accounts/migrations/0001_initial.py +++ b/backend/accounts/migrations/0001_initial.py @@ -1,46 +1,50 @@ -# Generated by Django 5.0.1 on 2024-01-06 04:34 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.utils.timezone -import django_resized.forms -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='CustomUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('avatar', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, null=True, quality=100, scale=None, size=[1920, 1080], upload_to='avatars/')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - ] +# Generated by Django 5.0.6 on 2024-05-10 06:37 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import django_resized.forms +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('user_groups', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('avatar', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, null=True, quality=100, scale=None, size=[1920, 1080], upload_to='avatars/')), + ('onboarding', models.BooleanField(default=True)), + ('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_group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='user_groups.usergroup')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/accounts/models.py b/backend/accounts/models.py index 4b7716b..1a8eb3e 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -1,10 +1,9 @@ from django.db import models from django.contrib.auth.models import AbstractUser from django.urls import reverse -from django.db.models.signals import post_migrate -from django.dispatch import receiver from django_resized import ResizedImageField -import os +from django.utils import timezone +from datetime import timedelta class CustomUser(AbstractUser): @@ -18,65 +17,44 @@ class CustomUser(AbstractUser): avatar = ResizedImageField( null=True, force_format="WEBP", quality=100, upload_to='avatars/') - def avatar_url(self): - return f'/api/v1/media/avatars/{self.avatar.field.storage.name(self.avatar.path)}' + # Used for onboarding processes + # Set this to False later on once the user makes actions + onboarding = models.BooleanField(default=True) + + user_group = models.ForeignKey( + 'user_groups.UserGroup', on_delete=models.SET_NULL, null=True) + + @property + def group_member(self): + if self.user_group: + return True + else: + return False + + # Can be used to show tooltips for newer users + @property + def is_new(self): + current_date = timezone.now() + return self.date_joined + timedelta(days=1) < current_date @property def full_name(self): return f"{self.first_name} {self.last_name}" + @property + def group_member(self): + if self.user_group: + return True + else: + return False + + @property + def group_owner(self): + if self.user_group and self == self.user_group.owner: + return True + else: + return False + @property def admin_url(self): return reverse('admin:users_customuser_change', args=(self.pk,)) - - pass - - -@receiver(post_migrate) -def create_superuser(sender, **kwargs): - if sender.name == 'accounts': - # Add test users here if needed - # They will automatically be created after migrating the db - users = [ - # Superadmin Account - { - 'username': os.getenv('DJANGO_ADMIN_USERNAME'), - 'email': os.getenv('DJANGO_ADMIN_EMAIL'), - 'password': os.getenv('DJANGO_ADMIN_PASSWORD'), - 'is_staff': True, - 'is_superuser': True, - 'first_name': 'Super', - 'last_name': 'Admin' - }, - # Debug User - { - 'username': 'debug-user', - 'email': os.getenv('DJANGO_ADMIN_EMAIL'), - 'password': os.getenv('DJANGO_ADMIN_PASSWORD'), - 'is_staff': False, - 'is_superuser': False, - 'first_name': "Test", - 'last_name': "User" - }, - ] - - for user in users: - if not CustomUser.objects.filter(username=user['username']).exists(): - if (user['is_superuser']): - USER = CustomUser.objects.create_superuser( - username=user['username'], - password=user['password'], - email=user['email'], - ) - print('Created Superuser:', user['username']) - else: - USER = CustomUser.objects.create_user( - username=user['username'], - password=user['password'], - email=user['email'], - ) - print('Created User:', user['username']) - USER.first_name = user['first_name'] - USER.last_name = user['last_name'] - USER.is_active = True - USER.save() diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py index d6f828f..9df1d89 100644 --- a/backend/accounts/serializers.py +++ b/backend/accounts/serializers.py @@ -1,52 +1,81 @@ -from djoser.serializers import UserCreateSerializer as BaseUserRegistrationSerializer from djoser.serializers import UserSerializer as BaseUserSerializer -from django.core import exceptions as django_exceptions +from rest_framework.serializers import ModelSerializer from rest_framework import serializers from accounts.models import CustomUser +from drf_extra_fields.fields import Base64ImageField +from user_groups.serializers import SimpleUserGroupSerializer +from django.core.cache import cache +from django.core import exceptions as django_exceptions from rest_framework.settings import api_settings from django.contrib.auth.password_validation import validate_password -from django.utils.encoding import smart_str -from drf_spectacular.utils import extend_schema_field -from drf_spectacular.types import OpenApiTypes -from drf_extra_fields.fields import Base64ImageField - # There can be multiple subject instances with the same name, only differing in course, year level, and semester. We filter them here +class SimpleCustomUserSerializer(ModelSerializer): + class Meta(BaseUserSerializer.Meta): + model = CustomUser + fields = ('id', 'username', 'email', 'full_name') + + class CustomUserSerializer(BaseUserSerializer): avatar = Base64ImageField() class Meta(BaseUserSerializer.Meta): model = CustomUser fields = ('id', 'username', 'email', 'avatar', 'first_name', - 'last_name') + 'last_name', 'user_group', 'group_member', 'group_owner') + read_only_fields = ('id', 'username', 'email', 'user_group', + 'group_member', 'group_owner') + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['user_group'] = SimpleUserGroupSerializer( + instance.user_group, many=False).data + return representation + + def update(self, instance, validated_data): + cache.delete(f'user:{instance.id}') + return super().update(instance, validated_data) class UserRegistrationSerializer(serializers.ModelSerializer): email = serializers.EmailField(required=True) + username = serializers.CharField(required=True) password = serializers.CharField( write_only=True, style={'input_type': 'password', 'placeholder': 'Password'}) + first_name = serializers.CharField( + required=True, allow_blank=False, allow_null=False) + last_name = serializers.CharField( + required=True, allow_blank=False, allow_null=False) class Meta: - model = CustomUser # Use your custom user model here - fields = ('username', 'email', 'password', 'avatar', - 'first_name', 'last_name') + model = CustomUser + fields = ['email', 'username', 'password', + 'first_name', 'last_name'] def validate(self, attrs): - user = self.Meta.model(**attrs) + user_attrs = attrs.copy() + user = self.Meta.model(**user_attrs) password = attrs.get("password") + try: validate_password(password, user) except django_exceptions.ValidationError as e: serializer_error = serializers.as_serializer_error(e) + errors = serializer_error[api_settings.NON_FIELD_ERRORS_KEY] + if len(errors) > 1: + raise serializers.ValidationError({"password": errors[0]}) + else: + raise serializers.ValidationError({"password": errors}) + if self.Meta.model.objects.filter(username=attrs.get("username")).exists(): raise serializers.ValidationError( - {"password": serializer_error[api_settings.NON_FIELD_ERRORS_KEY]} - ) - + "A user with that username already exists.") return super().validate(attrs) def create(self, validated_data): user = self.Meta.model(**validated_data) + user.username = validated_data['username'] + user.is_active = False user.set_password(validated_data['password']) user.save() diff --git a/backend/accounts/signals.py b/backend/accounts/signals.py new file mode 100644 index 0000000..14512a2 --- /dev/null +++ b/backend/accounts/signals.py @@ -0,0 +1,103 @@ +from django.db.models.signals import post_migrate +from django.dispatch import receiver +from config.settings import SEED_DATA, ROOT_DIR, get_secret +from django_celery_beat.models import PeriodicTask, CrontabSchedule +from .models import CustomUser +import os +import json + +# Function to fill in users table with test data on dev/staging + + +@receiver(post_migrate) +def create_users(sender, **kwargs): + if sender.name == "accounts": + with open(os.path.join(ROOT_DIR, 'seed_data.json'), "r") as f: + seed_data = json.loads(f.read()) + for user in seed_data['users']: + USER = CustomUser.objects.filter( + email=user['email']).first() + if not USER: + if user['password'] == 'USE_REGULAR': + password = get_secret('SEED_DATA_PASSWORD') + elif user['password'] == 'USE_ADMIN': + password = get_secret('DJANGO_ADMIN_PASSWORD') + else: + password = user['password'] + if (user['is_superuser'] == True): + # Admin users are created regardless of SEED_DATA value + USER = CustomUser.objects.create_superuser( + username=user['username'], + email=user['email'], + password=password, + ) + print('Created Superuser:', user['email']) + else: + # Only create non-admin users if SEED_DATA=True + if SEED_DATA: + USER = CustomUser.objects.create_user( + username=user['email'], + email=user['email'], + password=password, + ) + print('Created User:', user['email']) + + USER.first_name = user['first_name'] + USER.last_name = user['last_name'] + USER.is_active = True + USER.save() + + +@receiver(post_migrate) +def create_celery_beat_schedules(sender, **kwargs): + if sender.name == "django_celery_beat": + with open(os.path.join(ROOT_DIR, 'seed_data.json'), "r") as f: + seed_data = json.loads(f.read()) + # Creating Schedules + for schedule in seed_data['schedules']: + if schedule['type'] == 'crontab': + # Check if Schedule already exists + SCHEDULE = CrontabSchedule.objects.filter(minute=schedule['minute'], + hour=schedule['hour'], + day_of_week=schedule['day_of_week'], + day_of_month=schedule['day_of_month'], + month_of_year=schedule['month_of_year'], + timezone=schedule['timezone'] + ).first() + # If it does not exist, create a new Schedule + if not SCHEDULE: + SCHEDULE = CrontabSchedule.objects.create( + minute=schedule['minute'], + hour=schedule['hour'], + day_of_week=schedule['day_of_week'], + day_of_month=schedule['day_of_month'], + month_of_year=schedule['month_of_year'], + timezone=schedule['timezone'] + ) + print( + f'Created Crontab Schedule for Hour:{SCHEDULE.hour},Minute:{SCHEDULE.minute}') + else: + print( + f'Crontab Schedule for Hour:{SCHEDULE.hour},Minute:{SCHEDULE.minute} already exists') + for task in seed_data['scheduled_tasks']: + TASK = PeriodicTask.objects.filter(name=task['name']).first() + if not TASK: + if task['schedule']['type'] == 'crontab': + SCHEDULE = CrontabSchedule.objects.filter(minute=task['schedule']['minute'], + hour=task['schedule']['hour'], + day_of_week=task['schedule']['day_of_week'], + day_of_month=task['schedule']['day_of_month'], + month_of_year=task['schedule']['month_of_year'], + timezone=task['schedule']['timezone'] + ).first() + TASK = PeriodicTask.objects.create( + crontab=SCHEDULE, + name=task['name'], + task=task['task'], + enabled=task['enabled'] + ) + print(f'Created Periodic Task: {TASK.name}') + else: + raise Exception('Schedule for Periodic Task not found') + else: + print(f'Periodic Task: {TASK.name} already exists') diff --git a/backend/accounts/tasks.py b/backend/accounts/tasks.py new file mode 100644 index 0000000..784d5a9 --- /dev/null +++ b/backend/accounts/tasks.py @@ -0,0 +1,23 @@ +from celery import shared_task + + +@shared_task +def get_paying_users(): + from subscriptions.models import UserSubscription + # Get a list of user subscriptions + active_subscriptions = UserSubscription.objects.filter( + valid=True).distinct('user') + + # Get paying users + active_users = [] + + # Paying regular users + active_users += [ + subscription.user.id for subscription in active_subscriptions if subscription.user is not None and subscription.user.user_group is None] + + # Paying users within groups + active_users += [ + subscription.user_group.members for subscription in active_subscriptions if subscription.user_group is not None and subscription.user is None] + + # Return paying users + return active_users diff --git a/backend/accounts/urls.py b/backend/accounts/urls.py index 22c121e..5408e39 100644 --- a/backend/accounts/urls.py +++ b/backend/accounts/urls.py @@ -1,7 +1,12 @@ -from django.contrib import admin from django.urls import path, include +from rest_framework.routers import DefaultRouter +from accounts import views + +router = DefaultRouter() +router.register(r'users', views.CustomUserViewSet, basename='users') urlpatterns = [ + path('', include(router.urls)), path('', include('djoser.urls')), path('', include('djoser.urls.jwt')), ] diff --git a/backend/accounts/validators.py b/backend/accounts/validators.py new file mode 100644 index 0000000..4eb10ef --- /dev/null +++ b/backend/accounts/validators.py @@ -0,0 +1,44 @@ + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +import re + + +class UppercaseValidator(object): + def validate(self, password, user=None): + if not re.findall('[A-Z]', password): + raise ValidationError( + _("The password must contain at least 1 uppercase letter (A-Z).")) + + def get_help_text(self): + return _("Your password must contain at least 1 uppercase letter (A-Z).") + + +class LowercaseValidator(object): + def validate(self, password, user=None): + if not re.findall('[a-z]', password): + raise ValidationError( + _("The password must contain at least 1 lowercase letter (a-z).")) + + def get_help_text(self): + return _("Your password must contain at least 1 lowercase letter (a-z).") + + +class SpecialCharacterValidator(object): + def validate(self, password, user=None): + if not re.findall('[@#$%^&*()_+/\<>;:!?]', password): + raise ValidationError( + _("The password must contain at least 1 special character (@, #, $, etc.).")) + + def get_help_text(self): + return _("Your password must contain at least 1 special character (@, #, $, etc.).") + + +class NumberValidator(object): + def validate(self, password, user=None): + if not any(char.isdigit() for char in password): + raise ValidationError( + _("The password must contain at least one numerical digit (0-9).")) + + def get_help_text(self): + return _("Your password must contain at least numerical digit (0-9).") diff --git a/backend/accounts/views.py b/backend/accounts/views.py index 3a2ebcf..aac81c8 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -1,5 +1,118 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework import generics -from accounts.serializers import CustomUserSerializer -from rest_framework.response import Response +from rest_framework import status from accounts.models import CustomUser +from accounts import serializers +from rest_framework.decorators import action +from rest_framework.response import Response +from djoser.conf import settings +from djoser.views import UserViewSet as DjoserUserViewSet +from django.contrib.auth.tokens import default_token_generator +from djoser import signals +from djoser.compat import get_user_email +from django.core.cache import cache +from rest_framework.permissions import IsAuthenticated + + +class CustomUserViewSet(DjoserUserViewSet): + queryset = CustomUser.objects.all() + serializer_class = serializers.CustomUserSerializer + permission_classes = settings.PERMISSIONS.activation + token_generator = default_token_generator + + def get_queryset(self): + user = self.request.user + # If user is admin, show all active users + if user.is_superuser: + key = 'users' + # Get cache + queryset = cache.get(key) + # Set cache if stale or does not exist + if not queryset: + queryset = CustomUser.objects.filter(is_active=True) + cache.set(key, queryset, 60*60) + return queryset + elif not user.user_group: + key = f'user:{user.id}' + queryset = cache.get(key) + if not queryset: + queryset = CustomUser.objects.filter(is_active=True) + cache.set(key, queryset, 60*60) + return queryset + elif user.user_group: + key = f'usergroup_users:{user.user_group.id}' + queryset = cache.get(key) + if not queryset: + queryset = CustomUser.objects.filter( + user_group=user.user_group) + cache.set(key, queryset, 60*60) + return queryset + else: + return CustomUser.objects.none() + + def perform_update(self, serializer, *args, **kwargs): + user = self.request.user + + # Clear cache + cache.delete(f'users') + cache.delete(f'user:{user.id}') + if user.user_group: + cache.delete(f'usergroup_users:{user.user_group.id}') + + super().perform_update(serializer, *args, **kwargs) + user = serializer.instance + signals.user_updated.send( + sender=self.__class__, user=user, request=self.request + ) + if settings.SEND_ACTIVATION_EMAIL and not user.is_active: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) + + def perform_create(self, serializer, *args, **kwargs): + user = serializer.save(*args, **kwargs) + + # Silently error out if email sending fails + try: + signals.user_registered.send( + sender=self.__class__, user=user, request=self.request + ) + context = {"user": user} + to = [get_user_email(user)] + + if settings.SEND_ACTIVATION_EMAIL: + settings.EMAIL.activation(self.request, context).send(to) + elif settings.SEND_CONFIRMATION_EMAIL: + settings.EMAIL.confirmation(self.request, context).send(to) + + # Clear cache + cache.delete('users') + cache.delete(f'user:{user.id}') + if user.user_group: + cache.delete(f'usergroup_users:{user.user_group.id}') + + except Exception as e: + print('Warning: Unable to send email') + print(e) + + @action(methods=['post'], detail=False, url_path='activation', url_name='activation') + def activation(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.user + user.is_active = True + user.save() + + # Construct a response with user's first name, last name, and email + user_data = { + 'first_name': user.first_name, + 'last_name': user.last_name, + 'email': user.email, + 'username': user.username + } + + # Clear cache + cache.delete('users') + cache.delete(f'user:{user.id}') + if user.user_group: + cache.delete(f'usergroup_users:{user.user_group.id}') + + return Response(user_data, status=status.HTTP_200_OK) diff --git a/backend/api/urls.py b/backend/api/urls.py index 86fa82f..c59de67 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,14 +1,17 @@ from django.conf.urls.static import static from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import path, include -from config import settings +from config.settings import DEBUG, CLOUD, MEDIA_ROOT urlpatterns = [ path('accounts/', include('accounts.urls')), - + path('subscriptions/', include('subscriptions.urls')), + path('notifications/', include('notifications.urls')), + path('billing/', include('billing.urls')), + path('stripe/', include('payments.urls')) ] -# Media files -if settings.DEBUG: +# URLs for local development +if DEBUG and not CLOUD: urlpatterns += staticfiles_urlpatterns() urlpatterns += static( - 'media/', document_root=settings.MEDIA_ROOT) + 'media/', document_root=MEDIA_ROOT) diff --git a/backend/billing/__init__.py b/backend/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/billing/apps.py b/backend/billing/apps.py new file mode 100644 index 0000000..214eb57 --- /dev/null +++ b/backend/billing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "billing" diff --git a/backend/billing/migrations/__init__.py b/backend/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/billing/urls.py b/backend/billing/urls.py new file mode 100644 index 0000000..138f256 --- /dev/null +++ b/backend/billing/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from billing import views + +urlpatterns = [ + path('', + views.BillingHistoryView.as_view()), +] diff --git a/backend/billing/views.py b/backend/billing/views.py new file mode 100644 index 0000000..effadca --- /dev/null +++ b/backend/billing/views.py @@ -0,0 +1,61 @@ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from config.settings import STRIPE_SECRET_KEY +from django.core.cache import cache +from datetime import datetime +import stripe + + +# Make sure to set your secret key +stripe.api_key = STRIPE_SECRET_KEY + + +class BillingHistoryView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + requesting_user = self.request.user + + if requesting_user.user_group: + email = requesting_user.user_group.owner.email + else: + email = requesting_user.email + + # Check cache + key = f'billing_user:{requesting_user.id}' + billing_history = cache.get(key) + + if not billing_history: + # List customers and filter by email + customers = stripe.Customer.list(limit=1, email=email) + + if customers: + customer = customers.data[0] + + # List customers and filter by email + customers = stripe.Customer.list(limit=1, email=email) + + if len(customers.data) > 0: + # Retrieve the customer's charges (billing history) + charges = stripe.Charge.list( + limit=10, customer=customer.id) + + # Prepare the response + billing_history = [ + { + 'email': charge['billing_details']['email'], + 'amount_charged': int(charge['amount']/100), + 'paid': charge['paid'], + 'refunded': int(charge['amount_refunded']/100) > 0, + 'amount_refunded': int(charge['amount_refunded']/100), + 'last_4': charge['payment_method_details']['card']['last4'], + 'receipt_link': charge['receipt_url'], + 'timestamp': datetime.fromtimestamp(charge['created']).strftime("%m-%d-%Y %I:%M %p"), + } for charge in charges.auto_paging_iter() + ] + + cache.set(key, billing_history, 60*60) + + return Response(billing_history, status=status.HTTP_200_OK) diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e69de29..fb989c4 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/backend/config/celery.py b/backend/config/celery.py new file mode 100644 index 0000000..700a668 --- /dev/null +++ b/backend/config/celery.py @@ -0,0 +1,17 @@ +from celery import Celery +import os + + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('config') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() diff --git a/backend/config/settings.py b/backend/config/settings.py index 1af16ad..33ed6c5 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -12,50 +12,91 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ from datetime import timedelta from pathlib import Path -from dotenv import load_dotenv # Python dotenv +from dotenv import load_dotenv, find_dotenv # Python dotenv import os -load_dotenv() # loads the configs from .env - # Build paths inside the project like this: BASE_DIR / 'subdir'. +# Backend folder (/backend) BASE_DIR = Path(__file__).resolve().parent.parent +# Root folder where docker-compose.yml is located +ROOT_DIR = Path(__file__).resolve().parent.parent.parent + +# If you're hosting this on the cloud, have this set +CLOUD = bool(os.getenv('CLOUD', False)) + +load_dotenv(find_dotenv()) -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ +def get_secret(secret_name): + if CLOUD: + try: + pass + # Add specific implementations here if deploying to Azure, GCP, or AWS to get secrets + except: + secret_value = "" + else: + # Fallback to .env or system environment variables for local development + secret_value = os.getenv(secret_name) -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = str(os.getenv('SECRET_KEY')) + if secret_value is None: + raise ValueError(f"Secret '{secret_name}' not found.") + else: + return secret_value -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True + +# Frontend Domain +DOMAIN = get_secret('DOMAIN') +# Backend Domain +BACKEND_DOMAIN = get_secret('BACKEND_DOMAIN') +# URL Prefixes +USE_HTTPS = (get_secret('USE_HTTPS') == 'True') +URL_PREFIX = 'https://' if CLOUD and USE_HTTPS else 'http://' +BACKEND_URL = f'{URL_PREFIX}{BACKEND_DOMAIN}' +FRONTEND_URL = f'{URL_PREFIX}{DOMAIN}' ALLOWED_HOSTS = ['*'] -CSRF_TRUSTED_ORIGINS = ["https://testing.keannu1.duckdns.org"] +CSRF_TRUSTED_ORIGINS = [ + BACKEND_URL, + FRONTEND_URL +] +if CLOUD: + # TODO: If you require additional URLs to be trusted in cloud service providers, add them here + CSRF_TRUSTED_ORIGINS += [] + + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = (get_secret('BACKEND_DEBUG') == 'True') +# Determines whether or not to insert test data within tables +SEED_DATA = (get_secret('SEED_DATA') == 'True') +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = get_secret('SECRET_KEY') +# Selenium Config +# Initiate CAPTCHA solver in test mode +CAPTCHA_TESTING = (get_secret('CAPTCHA_TESTING') == 'True') +# If using Selenium and/or the provided CAPTCHA solver, determines whether or not to use proxies +USE_PROXY = (get_secret('USE_PROXY') == 'True') + +# Stripe (For payments) +STRIPE_SECRET_KEY = get_secret( + "STRIPE_SECRET_KEY") +STRIPE_SECRET_WEBHOOK = get_secret('STRIPE_SECRET_WEBHOOK') +STRIPE_CHECKOUT_URL = f'' # Email credentials -EMAIL_HOST = '' -EMAIL_HOST_USER = '' -EMAIL_HOST_PASSWORD = '' -EMAIL_PORT = '' -EMAIL_USE_TLS = False - -if (DEBUG == True): - EMAIL_HOST = str(os.getenv('DEV_EMAIL_HOST')) - EMAIL_HOST_USER = str(os.getenv('DEV_EMAIL_HOST_USER')) - EMAIL_HOST_PASSWORD = str(os.getenv('DEV_EMAIL_HOST_PASSWORD')) - EMAIL_PORT = str(os.getenv('DEV_EMAIL_PORT')) -else: - EMAIL_HOST = str(os.getenv('PROD_EMAIL_HOST')) - EMAIL_HOST_USER = str(os.getenv('PROD_EMAIL_HOST_USER')) - EMAIL_HOST_PASSWORD = str(os.getenv('PROD_EMAIL_HOST_PASSWORD')) - EMAIL_PORT = str(os.getenv('PROD_EMAIL_PORT')) - EMAIL_USE_TLS = str(os.getenv('PROD_EMAIL_TLS')) +EMAIL_HOST = get_secret('EMAIL_HOST') +EMAIL_HOST_USER = get_secret('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = get_secret('EMAIL_HOST_PASSWORD') +EMAIL_PORT = get_secret('EMAIL_PORT') +EMAIL_USE_TLS = get_secret('EMAIL_USE_TLS') +EMAIL_ADDRESS = (get_secret('EMAIL_ADDRESS') == 'True') # Application definition INSTALLED_APPS = [ + 'silk', + 'config', 'unfold', + 'unfold.contrib.filters', 'unfold.contrib.simple_history', 'django.contrib.admin', 'django.contrib.auth', @@ -63,20 +104,32 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'storages', + 'django_extensions', 'rest_framework', 'rest_framework_simplejwt', + 'django_celery_results', + 'django_celery_beat', 'simple_history', 'djoser', 'corsheaders', 'drf_spectacular', 'drf_spectacular_sidecar', + 'webdriver', 'accounts', + 'user_groups', + 'subscriptions', + 'payments', + 'billing', + 'emails', + 'notifications' ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware", - 'django.contrib.sessions.middleware.SessionMiddleware', + "silk.middleware.SilkyMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -88,17 +141,50 @@ MIDDLEWARE = [ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = 'static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'static') -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" -MEDIA_URL = 'api/v1/media/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') ROOT_URLCONF = 'config.urls' +if CLOUD: + # Cloud Storage Settings + CLOUD_BUCKET = get_secret('CLOUD_BUCKET') + CLOUD_BUCKET_CONTAINER = get_secret('CLOUD_BUCKET_CONTAINER') + CLOUD_STATIC_CONTAINER = get_secret('CLOUD_STATIC_CONTAINER') + + MEDIA_URL = f'https://{CLOUD_BUCKET}/{CLOUD_BUCKET_CONTAINER}/' + MEDIA_ROOT = f'https://{CLOUD_BUCKET}/' + + STATIC_URL = f'https://{CLOUD_BUCKET}/{CLOUD_STATIC_CONTAINER}/' + STATIC_ROOT = f'https://{CLOUD_BUCKET}/{CLOUD_STATIC_CONTAINER}/' + + # Consult django-storages documentation when filling in these values. This will vary depending on your cloud service provider + STORAGES = { + 'default': { + # TODO: Set this up here if you're using cloud storage + 'BACKEND': None, + 'OPTIONS': { + # Optional parameters + }, + }, + 'staticfiles': { + # TODO: Set this up here if you're using cloud storage + 'BACKEND': None, + 'OPTIONS': { + # Optional parameters + }, + }, + } +else: + STATIC_URL = 'static/' + STATIC_ROOT = os.path.join(BASE_DIR, 'static') + STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + MEDIA_URL = 'api/v1/media/' + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + ROOT_URLCONF = 'config.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + BASE_DIR / 'templates', + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -135,26 +221,30 @@ REST_FRAMEWORK = { # DRF-Spectacular SPECTACULAR_SETTINGS = { - 'TITLE': 'Test Backend', - 'DESCRIPTION': 'A Project by Keannu Bernasol', + 'TITLE': 'DRF-Template', + 'DESCRIPTION': 'A Template Project by Keannu Bernasol', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, 'SWAGGER_UI_DIST': 'SIDECAR', 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', 'REDOC_DIST': 'SIDECAR', - # OTHER SETTINGS } WSGI_APPLICATION = 'config.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/4.2/ref/settings/#databases - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.postgresql", + # Have this set to True if you're using a connection bouncer + 'DISABLE_SERVER_SIDE_CURSORS': True, + "NAME": get_secret("DB_DATABASE"), + "USER": get_secret("DB_USERNAME"), + "PASSWORD": get_secret("DB_PASSWORD"), + "HOST": get_secret("DB_HOST"), + "PORT": get_secret("DB_PORT"), + "OPTIONS": { + "sslmode": get_secret("DB_SSL_MODE") + }, } } @@ -166,11 +256,22 @@ DJOSER = { 'PASSWORD_RESET_CONFIRM_URL': 'reset_password_confirm/{uid}/{token}', 'ACTIVATION_URL': 'activation/{uid}/{token}', 'USER_AUTHENTICATION_RULES': ['djoser.authentication.TokenAuthenticationRule'], + 'EMAIL': { + 'activation': 'emails.templates.ActivationEmail', + 'password_reset': 'emails.templates.PasswordResetEmail' + }, 'SERIALIZERS': { 'user': 'accounts.serializers.CustomUserSerializer', 'current_user': 'accounts.serializers.CustomUserSerializer', 'user_create': 'accounts.serializers.UserRegistrationSerializer', }, + 'PERMISSIONS': { + # Disable some unneeded endpoints by setting them to admin only + 'username_reset': ['rest_framework.permissions.IsAdminUser'], + 'username_reset_confirm': ['rest_framework.permissions.IsAdminUser'], + 'set_username': ['rest_framework.permissions.IsAdminUser'], + 'set_password': ['rest_framework.permissions.IsAdminUser'], + } } # Password validation @@ -182,6 +283,9 @@ AUTH_PASSWORD_VALIDATORS = [ }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "OPTIONS": { + "min_length": 8, + } }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', @@ -189,6 +293,19 @@ AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, + # Additional password validators + { + 'NAME': 'accounts.validators.SpecialCharacterValidator', + }, + { + 'NAME': 'accounts.validators.LowercaseValidator', + }, + { + 'NAME': 'accounts.validators.UppercaseValidator', + }, + { + 'NAME': 'accounts.validators.NumberValidator', + }, ] @@ -209,18 +326,61 @@ USE_TZ = True DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -DOMAIN = 'testing.keannu1.duckdns.org/#' +SITE_NAME = 'DRF-Template' -SITE_NAME = 'Test Backend' - -# 1 week access token lifetime +# JWT Token Lifetimes SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), - "REFRESH_TOKEN_LIFETIME": timedelta(minutes=10080) + "ACCESS_TOKEN_LIFETIME": timedelta(hours=6), + "REFRESH_TOKEN_LIFETIME": timedelta(days=3) } -SESSION_ENGINE = "django.contrib.sessions.backends.cache" -SESSION_CACHE_ALIAS = "default" - CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_CREDENTIALS = True + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, +} +DJANGO_LOG_LEVEL = "DEBUG" +# Enables VS Code debugger to break on raised exceptions +DEBUG_PROPAGATE_EXCEPTIONS = "DEBUG" + +# Celery Configuration Options +CELERY_TIMEZONE = TIME_ZONE +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 +CELERY_BROKER_URL = get_secret("CELERY_BROKER") +CELERY_BROKER = get_secret("CELERY_BROKER") +CELERY_BACKEND = get_secret("CELERY_BROKER") +CELERY_RESULT_BACKEND = get_secret("CELERY_RESULT_BACKEND") +CELERY_RESULT_EXTENDED = True + +# Celery Beat Options +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + +# Maximum number of rows that can be updated within the Django admin panel +DATA_UPLOAD_MAX_NUMBER_FIELDS = 20480 + +GRAPH_MODELS = { + 'app_labels': ['accounts', 'user_groups', 'billing', 'emails', 'payments', 'subscriptions'] +} + +# Django/DRF Cache +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://{get_secret('REDIS_HOST')}:{get_secret('REDIS_PORT')}/2", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} diff --git a/backend/config/urls.py b/backend/config/urls.py index 8c0ff9e..361ebbb 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -17,6 +17,7 @@ Including another URLconf from django.contrib import admin from django.urls import path, include from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +from config.settings import DEBUG urlpatterns = [ path('admin/', admin.site.urls), @@ -27,3 +28,6 @@ urlpatterns = [ path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), ] + +if DEBUG: + urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))] diff --git a/backend/emails/__init__.py b/backend/emails/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/emails/apps.py b/backend/emails/apps.py new file mode 100644 index 0000000..20e925a --- /dev/null +++ b/backend/emails/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EmailsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'emails' diff --git a/backend/emails/templates.py b/backend/emails/templates.py new file mode 100644 index 0000000..25d0f7d --- /dev/null +++ b/backend/emails/templates.py @@ -0,0 +1,49 @@ +from djoser import email +from django.utils import timezone + + +class ActivationEmail(email.ActivationEmail): + template_name = 'templates/email_activation.html' + + +class PasswordResetEmail(email.PasswordResetEmail): + template_name = 'templates/password_change.html' + + +class SubscriptionAvailedEmail(email.BaseEmailMessage): + template_name = "templates/subscription_availed.html" + + def get_context_data(self): + context = super().get_context_data() + context["user"] = context.get("user") + context["subscription_plan"] = context.get("subscription_plan") + context["subscription"] = context.get("subscription") + context["price_paid"] = context.get("price_paid") + context['date'] = timezone.now().strftime("%B %d, %I:%M %p") + context.update(self.context) + return context + + +class SubscriptionRefundedEmail(email.BaseEmailMessage): + template_name = "templates/subscription_refunded.html" + + def get_context_data(self): + context = super().get_context_data() + context["user"] = context.get("user") + context["subscription_plan"] = context.get("subscription_plan") + context["refund"] = context.get("refund") + context['date'] = timezone.now().strftime("%B %d, %I:%M %p") + context.update(self.context) + return context + + +class SubscriptionCancelledEmail(email.BaseEmailMessage): + template_name = "templates/subscription_cancelled.html" + + def get_context_data(self): + context = super().get_context_data() + context["user"] = context.get("user") + context["subscription_plan"] = context.get("subscription_plan") + context['date'] = timezone.now().strftime("%B %d, %I:%M %p") + context.update(self.context) + return context diff --git a/backend/emails/templates/activation.html b/backend/emails/templates/activation.html new file mode 100644 index 0000000..f36649e --- /dev/null +++ b/backend/emails/templates/activation.html @@ -0,0 +1,28 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Account activation on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you need to finish activation process on {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page to activate account:" %} +{{ protocol }}://{{ domain }}/{{ url|safe }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% blocktrans %}You're receiving this email because you need to finish activation process on {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Please go to the following page to activate account:" %}

+

{{ protocol }}://{{ domain }}/{{ url|safe }}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+ +{% endblock html_body %} diff --git a/backend/emails/templates/password_reset.html b/backend/emails/templates/password_reset.html new file mode 100644 index 0000000..19244aa --- /dev/null +++ b/backend/emails/templates/password_reset.html @@ -0,0 +1,29 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Password reset on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new password:" %} +{{ protocol }}://{{ domain }}/{{ url|safe }} +{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Please go to the following page and choose a new password:" %}

+{{ protocol }}://{{ domain }}/{{ url|safe }} +

{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+{% endblock html_body %} diff --git a/backend/emails/templates/subscription_availed.html b/backend/emails/templates/subscription_availed.html new file mode 100644 index 0000000..000be12 --- /dev/null +++ b/backend/emails/templates/subscription_availed.html @@ -0,0 +1,20 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Subscription Availed on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you availed the {{ subscription_plan.name }} amounting to {{ price_paid.amount }}{{ price_paid.currency}} at {{ site_name }}.{% endblocktrans %} + +{% trans "Thanks for using our site!" %} + +{% endblock text_body %} + +{% block html_body %} + +

{% blocktrans %}You're receiving this email because you availed the {{ subscription_plan.name }} at {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Thanks for using our site!" %}

+ +{% endblock html_body %} diff --git a/backend/emails/templates/subscription_cancelled.html b/backend/emails/templates/subscription_cancelled.html new file mode 100644 index 0000000..6fd638a --- /dev/null +++ b/backend/emails/templates/subscription_cancelled.html @@ -0,0 +1,24 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Subscription Cancelled on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been cancelled at {{ site_name }}.{% endblocktrans %} + +{% trans "Thanks for using our site!" %} + +{% endblock text_body %} + +{% block html_body %} + +

+ {% blocktrans %} + You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been cancelled at {{ site_name }}. + {% endblocktrans %} +

+ +

{% trans "Thanks for using our site!" %}

+ +{% endblock html_body %} diff --git a/backend/emails/templates/subscription_refunded.html b/backend/emails/templates/subscription_refunded.html new file mode 100644 index 0000000..449e28e --- /dev/null +++ b/backend/emails/templates/subscription_refunded.html @@ -0,0 +1,20 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Subscription Refunded on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been refunded at {{ site_name }}. You have been refunded {{ refund.amount }}{{ refund.currency }}{% endblocktrans %} + +{% trans "Thanks for using our site!" %} + +{% endblock text_body %} + +{% block html_body %} + +

{% blocktrans %}You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been refunded at {{ site_name }}. You have been refunded {{ refund.amount }}{{ refund.currency }}{% endblocktrans %}

+ +

{% trans "Thanks for using our site!" %}

+ +{% endblock html_body %} diff --git a/backend/notifications/__init__.py b/backend/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py new file mode 100644 index 0000000..6a0122a --- /dev/null +++ b/backend/notifications/admin.py @@ -0,0 +1,10 @@ +from unfold.admin import ModelAdmin +from django.contrib import admin +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(ModelAdmin): + model = Notification + search_fields = ('id', 'content') + list_display = ['id', 'dismissed'] diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py new file mode 100644 index 0000000..05047b5 --- /dev/null +++ b/backend/notifications/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notifications' + + def ready(self): + import notifications.signals diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..f2bddc7 --- /dev/null +++ b/backend/notifications/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.6 on 2024-05-10 13:56 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.CharField(max_length=1000, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('dismissed', models.BooleanField(default=False)), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/notifications/migrations/__init__.py b/backend/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notifications/models.py b/backend/notifications/models.py new file mode 100644 index 0000000..75e0b4f --- /dev/null +++ b/backend/notifications/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class Notification(models.Model): + recipient = models.ForeignKey( + 'accounts.CustomUser', on_delete=models.CASCADE) + content = models.CharField(max_length=1000, null=True) + timestamp = models.DateTimeField(auto_now_add=True, editable=False) + dismissed = models.BooleanField(default=False) + + def __str__(self): + return self.content diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py new file mode 100644 index 0000000..7432852 --- /dev/null +++ b/backend/notifications/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from notifications.models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + timestamp = serializers.DateTimeField( + format="%m-%d-%Y %I:%M %p", read_only=True) + + class Meta: + model = Notification + fields = '__all__' + read_only_fields = ('id', 'recipient', 'content', 'timestamp') diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py new file mode 100644 index 0000000..c2d7539 --- /dev/null +++ b/backend/notifications/signals.py @@ -0,0 +1,13 @@ +from django.dispatch import receiver +from django.db.models.signals import post_save +from notifications.models import Notification +from django.core.cache import cache + +# Template for running actions after user have paid for a subscription + + +@receiver(post_save, sender=Notification) +def clear_cache_after_notification_update(sender, instance, **kwargs): + # Clear cache + cache.delete('notifications') + cache.delete(f'notifications_user:{instance.recipient.id}') diff --git a/backend/notifications/tasks.py b/backend/notifications/tasks.py new file mode 100644 index 0000000..d6dd821 --- /dev/null +++ b/backend/notifications/tasks.py @@ -0,0 +1,13 @@ +from celery import shared_task +from django.utils import timezone +from notifications.models import Notification + + +@shared_task +def cleanup_notifications(): + # Calculate the date 3 days ago + three_days_ago = timezone.now() - timezone.timedelta(days=3) + + # Delete notifications that are older than 3 days and dismissed + Notification.objects.filter( + dismissed=True, timestamp__lte=three_days_ago).delete() diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py new file mode 100644 index 0000000..9cbf42a --- /dev/null +++ b/backend/notifications/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from notifications.views import NotificationViewSet +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register(r'', NotificationViewSet, + basename="Notifications") +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/notifications/views.py b/backend/notifications/views.py new file mode 100644 index 0000000..12a79fa --- /dev/null +++ b/backend/notifications/views.py @@ -0,0 +1,35 @@ +from rest_framework import viewsets +from notifications.models import Notification +from notifications.serializers import NotificationSerializer +from rest_framework.exceptions import PermissionDenied +from django.core.cache import cache + + +class NotificationViewSet(viewsets.ModelViewSet): + http_method_names = ['get', 'patch', 'delete'] + serializer_class = NotificationSerializer + queryset = Notification.objects.all() + + def get_queryset(self): + user = self.request.user + key = f'notifications_user:{user.id}' + queryset = cache.get(key) + if not queryset: + queryset = Notification.objects.filter( + recipient=user).order_by('-timestamp') + cache.set(key, queryset, 60*60) + return queryset + + def update(self, request, *args, **kwargs): + instance = self.get_object() + if instance.recipient != request.user: + raise PermissionDenied( + "You do not have permission to update this notification.") + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.recipient != request.user: + raise PermissionDenied( + "You do not have permission to delete this notification.") + return super().destroy(request, *args, **kwargs) diff --git a/backend/payments/__init__.py b/backend/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/payments/apps.py b/backend/payments/apps.py new file mode 100644 index 0000000..4886655 --- /dev/null +++ b/backend/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'payments' diff --git a/backend/payments/migrations/__init__.py b/backend/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/payments/serializers.py b/backend/payments/serializers.py new file mode 100644 index 0000000..4597050 --- /dev/null +++ b/backend/payments/serializers.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class CheckoutSerializer(serializers.Serializer): + subscription_id = serializers.IntegerField() + annual = serializers.BooleanField() diff --git a/backend/payments/urls.py b/backend/payments/urls.py new file mode 100644 index 0000000..66ac721 --- /dev/null +++ b/backend/payments/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from payments import views + + +urlpatterns = [ + path('checkout_session/', views.StripeCheckoutView.as_view()), + path('webhook/', views.stripe_webhook_view, name='Stripe Webhook'), +] diff --git a/backend/payments/views.py b/backend/payments/views.py new file mode 100644 index 0000000..a7bf799 --- /dev/null +++ b/backend/payments/views.py @@ -0,0 +1,398 @@ +from config.settings import STRIPE_SECRET_KEY, DOMAIN, STRIPE_SECRET_WEBHOOK, CLOUD, BACKEND_URL, FRONTEND_URL +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from rest_framework.response import Response +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from rest_framework import status +import logging +import stripe +from subscriptions.models import SubscriptionPlan, UserSubscription +from accounts.models import CustomUser +from rest_framework.decorators import api_view +from subscriptions.tasks import get_user_subscription +import json +from emails.templates import SubscriptionAvailedEmail, SubscriptionRefundedEmail, SubscriptionCancelledEmail +from django.core.cache import cache +from payments.serializers import CheckoutSerializer +from drf_spectacular.utils import extend_schema +stripe.api_key = STRIPE_SECRET_KEY + + +@extend_schema( + request=CheckoutSerializer +) +class StripeCheckoutView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + # Get subscription ID from POST + USER = CustomUser.objects.get(id=self.request.user.id) + data = json.loads(request.body) + subscription_id = data.get('subscription_id') + annual = data.get('annual') + + # Validation for subscription_id field + try: + subscription_id = int(subscription_id) + except: + return Response({ + 'error': 'Invalid value specified in subscription_id field' + }, status=status.HTTP_403_FORBIDDEN) + + # Validation for annual field + try: + annual = bool(annual) + except: + return Response({ + 'error': 'Invalid value specified in annual field' + }, status=status.HTTP_403_FORBIDDEN) + + # Return an error if the user already has an active subscription + EXISTING_SUBSCRIPTION = get_user_subscription(USER.id) + if EXISTING_SUBSCRIPTION: + return Response({ + 'error': f'User is already subscribed to: {EXISTING_SUBSCRIPTION.subscription.name}' + }, status=status.HTTP_403_FORBIDDEN) + + # Attempt to query the subscription + SUBSCRIPTION = SubscriptionPlan.objects.filter( + id=subscription_id).first() + + # Return an error if the plan does not exist + if not SUBSCRIPTION: + return Response({ + 'error': 'Subscription plan not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Get the stripe_price_id from the related StripePrice instances + PRICE = None + PRICE_ID = None + + if annual: + PRICE = SUBSCRIPTION.annual_price + PRICE_ID = PRICE.stripe_price_id + else: + PRICE = SUBSCRIPTION.monthly_price + PRICE_ID = PRICE.stripe_price_id + + # Return 404 if no price is set + if not PRICE or PRICE_ID: + return Response({ + 'error': 'Specified price does not exist for plan' + }, status=status.HTTP_404_NOT_FOUND) + + prorated = PRICE.prorated + + # Return an error if a user is in a user_group and is availing pro-rated plans + if not USER.user_group and SUBSCRIPTION.group_exclusive: + return Response({ + 'error': 'Regular users cannot avail prorated plans' + }, status=status.HTTP_403_FORBIDDEN) + + success_url = FRONTEND_URL + \ + '/user/subscription/payment?success=true&agency=False&session_id={CHECKOUT_SESSION_ID}' + cancel_url = FRONTEND_URL + '/user/subscription/payment?success=false&user_group=False' + + checkout_session = stripe.checkout.Session.create( + line_items=[ + { + 'price': PRICE_ID, + 'quantity': 1 + } if not prorated else + { + 'price': PRICE_ID, + } + ], + mode='subscription', + payment_method_types=['card'], + success_url=success_url, + cancel_url=cancel_url, + ) + return Response({"url": checkout_session.url}) + except Exception as e: + logging.error(str(e)) + return Response({ + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@ api_view(['POST']) +@ csrf_exempt +def stripe_webhook_view(request): + payload = request.body + sig_header = request.META['HTTP_STRIPE_SIGNATURE'] + event = None + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, STRIPE_SECRET_WEBHOOK + ) + except ValueError: + # Invalid payload + return Response(status=400) + except stripe.error.SignatureVerificationError: + # Invalid signature + return Response(status=401) + + if event['type'] == 'customer.subscription.created': + subscription = event['data']['object'] + # Get the Invoice object from the Subscription object + invoice = stripe.Invoice.retrieve(subscription['latest_invoice'])[ + 'data']['object'] + # Get the Charge object from the Invoice object + charge = stripe.Charge.retrieve(invoice['charge'])['data']['object'] + + # Get paying user + customer = stripe.Customer.retrieve(subscription["customer"]) + USER = CustomUser.objects.filter(email=customer.email).first() + + product = subscription["items"]["data"][0] + SUBSCRIPTION_PLAN = SubscriptionPlan.objects.get( + stripe_product_id=product["plan"]["product"]) + SUBSCRIPTION = UserSubscription.objects.create( + subscription=SUBSCRIPTION_PLAN, + annual=product["plan"]["interval"] == "year", + valid=True, + user=USER, + stripe_id=subscription['id']) + email = SubscriptionAvailedEmail() + + paid = { + "amount": charge['amount']/100, + "currency": str(charge['currency']).upper() + } + + email.context = { + "user": USER, + "subscription_plan": SUBSCRIPTION_PLAN, + "subscription": SUBSCRIPTION, + "price_paid": paid, + } + email.send(to=[customer.email]) + + # Clear cache + cache.delete(f'billing_user:{USER.id}') + cache.delete(f'subscriptions_user:{USER.id}') + + # On chargebacks/refunds, invalidate the subscription + elif event['type'] == 'charge.refunded': + charge = event['data']['object'] + + # Get the Invoice object from the Charge object + invoice = stripe.Invoice.retrieve(charge['invoice']) + + # Check if the subscription exists + SUBSCRIPTION = UserSubscription.objects.filter( + stripe_id=invoice['subscription']).first() + + if not (SUBSCRIPTION): + return HttpResponse(status=404) + + if SUBSCRIPTION.user: + USER = SUBSCRIPTION.user + + # Mark refunded subscription as invalid + SUBSCRIPTION.valid = False + SUBSCRIPTION.save() + + SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription + + refund = { + "amount": charge['amount_refunded']/100, + "currency": str(charge['currency']).upper() + } + + # Send an email + email = SubscriptionRefundedEmail() + + email.context = { + "user": USER, + "subscription_plan": SUBSCRIPTION_PLAN, + "refund": refund + } + + email.send(to=[USER.email]) + + # Clear cache + cache.delete(f'billing_user:{USER.id}') + + elif SUBSCRIPTION.user_group: + OWNER = SUBSCRIPTION.user_group.owner + # Mark refunded subscription as invalid + SUBSCRIPTION.valid = False + SUBSCRIPTION.save() + + SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription + + refund = { + "amount": charge['amount_refunded']/100, + "currency": str(charge['currency']).upper() + } + + # Send en email + email = SubscriptionRefundedEmail() + + email.context = { + "user": OWNER, + "subscription_plan": SUBSCRIPTION_PLAN, + "refund": refund + } + email.send(to=[OWNER.email]) + + # Clear cache + cache.delete(f'billing_user:{USER.id}') + cache.delete(f'subscriptions_user:{USER.id}') + + elif event['type'] == 'customer.subscription.updated': + subscription = event['data']['object'] + + # Check if the subscription exists + SUBSCRIPTION = UserSubscription.objects.filter( + stripe_id=subscription['id']).first() + + if not (SUBSCRIPTION): + return HttpResponse(status=404) + + # Check if a subscription has been upgraded/downgraded + new_stripe_product_id = subscription['items']['data'][0]['plan']['product'] + current_stripe_product_id = SUBSCRIPTION.subscription.stripe_product_id + if new_stripe_product_id != current_stripe_product_id: + SUBSCRIPTION_PLAN = SubscriptionPlan.objects.get( + stripe_product_id=new_stripe_product_id) + SUBSCRIPTION.subscription = SUBSCRIPTION_PLAN + SUBSCRIPTION.save() + # TODO: Add a plan upgraded email message here + + # Subscription activation/reactivation + if subscription['status'] == 'active': + SUBSCRIPTION.valid = True + SUBSCRIPTION.save() + + if SUBSCRIPTION.user: + USER = SUBSCRIPTION.user + + # Clear cache + cache.delete(f'billing_user:{USER.id}') + cache.delete( + f'subscriptions_user:{USER.id}') + + elif SUBSCRIPTION.user_group: + OWNER = SUBSCRIPTION.user_group.owner + + # Clear cache + cache.delete(f'billing_user:{OWNER.id}') + cache.delete( + f'subscriptions_usergroup:{SUBSCRIPTION.user_group.id}') + + # TODO: Add notification here to inform users if their plan has been reactivated + + elif subscription['status'] == 'past_due': + # TODO: Add notification here to inform users if their payment method for an existing subscription payment is failing + pass + + # If subscriptions get cancelled due to non-payment, invalidate the UserSubscription + elif subscription['status'] == 'cancelled': + if SUBSCRIPTION.user: + USER = SUBSCRIPTION.user + + # Mark refunded subscription as invalid + SUBSCRIPTION.valid = False + SUBSCRIPTION.save() + + SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription + + # Send an email + email = SubscriptionCancelledEmail() + + email.context = { + "user": USER, + "subscription_plan": SUBSCRIPTION_PLAN, + "user_group": False, + } + email.send(to=[USER.email]) + + # Clear cache + cache.delete(f'billing_user:{USER.id}') + cache.delete(f'subscriptions_user:{USER.id}') + + elif SUBSCRIPTION.user_group: + OWNER = SUBSCRIPTION.user_group.owner + + # Mark refunded subscription as invalid + SUBSCRIPTION.valid = False + SUBSCRIPTION.save() + + # Send an email + email = SubscriptionCancelledEmail() + + SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription + + email.context = { + "user": OWNER, + "subscription_plan": SUBSCRIPTION_PLAN + } + email.send(to=[OWNER.email]) + + # Clear cache + cache.delete(f'billing_user:{OWNER.id}') + cache.delete( + f'subscriptions_usergroup:{SUBSCRIPTION.user_group.id}') + + # If a subscription gets cancelled, invalidate it + elif event['type'] == 'customer.subscription.deleted': + subscription = event['data']['object'] + + # Check if the subscription exists + SUBSCRIPTION = UserSubscription.objects.filter( + stripe_id=subscription['id']).first() + + if not (SUBSCRIPTION): + return HttpResponse(status=404) + + if SUBSCRIPTION.user: + USER = SUBSCRIPTION.user + + # Mark refunded subscription as invalid + SUBSCRIPTION.valid = False + SUBSCRIPTION.save() + + SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription + + # Send an email + email = SubscriptionCancelledEmail() + + email.context = { + "user": USER, + "subscription_plan": SUBSCRIPTION_PLAN, + "user_group": False, + } + email.send(to=[USER.email]) + + # Clear cache + cache.delete(f'billing_user:{USER.id}') + + elif SUBSCRIPTION.user_group: + OWNER = SUBSCRIPTION.user_group.owner + + # Mark refunded subscription as invalid + SUBSCRIPTION.valid = False + SUBSCRIPTION.save() + + # Send an email + email = SubscriptionCancelledEmail() + + SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription + + email.context = { + "user": OWNER, + "subscription_plan": SUBSCRIPTION_PLAN + } + email.send(to=[OWNER.email]) + + # Clear cache + cache.delete(f'billing_user:{OWNER.id}') + + # Passed signature verification + return HttpResponse(status=200) diff --git a/backend/schema.yml b/backend/schema.yml index e1c6d3d..743c2ff 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: - title: Test Backend + title: DRF-Template version: 1.0.0 - description: A Project by Keannu Bernasol + description: A Template Project by Keannu Bernasol paths: /api/v1/accounts/jwt/create/: post: @@ -141,6 +141,7 @@ paths: - api security: - jwtAuth: [] + - {} responses: '200': content: @@ -173,6 +174,7 @@ paths: required: true security: - jwtAuth: [] + - {} responses: '200': content: @@ -204,6 +206,7 @@ paths: $ref: '#/components/schemas/PatchedCustomUser' security: - jwtAuth: [] + - {} responses: '200': content: @@ -261,6 +264,7 @@ paths: - api security: - jwtAuth: [] + - {} responses: '200': content: @@ -286,6 +290,7 @@ paths: required: true security: - jwtAuth: [] + - {} responses: '200': content: @@ -310,6 +315,7 @@ paths: $ref: '#/components/schemas/PatchedCustomUser' security: - jwtAuth: [] + - {} responses: '200': content: @@ -426,7 +432,6 @@ paths: required: true security: - jwtAuth: [] - - {} responses: '200': content: @@ -453,7 +458,6 @@ paths: required: true security: - jwtAuth: [] - - {} responses: '200': content: @@ -513,6 +517,250 @@ paths: schema: $ref: '#/components/schemas/SetUsername' description: '' + /api/v1/billing/: + get: + operationId: api_v1_billing_retrieve + tags: + - api + security: + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/notifications/: + get: + operationId: api_v1_notifications_list + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + description: '' + /api/v1/notifications/{id}/: + get: + operationId: api_v1_notifications_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + patch: + operationId: api_v1_notifications_partial_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedNotification' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedNotification' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedNotification' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + delete: + operationId: api_v1_notifications_destroy + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/stripe/checkout_session/: + post: + operationId: api_v1_stripe_checkout_session_create + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Checkout' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Checkout' + multipart/form-data: + schema: + $ref: '#/components/schemas/Checkout' + required: true + security: + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/stripe/webhook/: + post: + operationId: api_v1_stripe_webhook_create + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/subscriptions/plans/: + get: + operationId: api_v1_subscriptions_plans_list + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SubscriptionPlan' + description: '' + /api/v1/subscriptions/plans/{id}/: + get: + operationId: api_v1_subscriptions_plans_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this subscription plan. + required: true + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionPlan' + description: '' + /api/v1/subscriptions/self/: + get: + operationId: api_v1_subscriptions_self_list + tags: + - api + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserSubscription' + description: '' + /api/v1/subscriptions/self/{id}/: + get: + operationId: api_v1_subscriptions_self_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this user subscription. + required: true + tags: + - api + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSubscription' + description: '' + /api/v1/subscriptions/user_group/: + get: + operationId: api_v1_subscriptions_user_group_list + tags: + - api + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserSubscription' + description: '' + /api/v1/subscriptions/user_group/{id}/: + get: + operationId: api_v1_subscriptions_user_group_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this user subscription. + required: true + tags: + - api + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSubscription' + description: '' components: schemas: Activation: @@ -525,6 +773,16 @@ components: required: - token - uid + Checkout: + type: object + properties: + subscription_id: + type: integer + annual: + type: boolean + required: + - annual + - subscription_id CustomUser: type: object properties: @@ -539,8 +797,8 @@ components: email: type: string format: email + readOnly: true title: Email address - maxLength: 254 avatar: type: string format: uri @@ -550,10 +808,48 @@ components: last_name: type: string maxLength: 150 + user_group: + type: integer + readOnly: true + nullable: true + group_member: + type: string + readOnly: true + group_owner: + type: string + readOnly: true required: - avatar + - email + - group_member + - group_owner - id + - user_group - username + Notification: + type: object + properties: + id: + type: integer + readOnly: true + timestamp: + type: string + format: date-time + readOnly: true + content: + type: string + readOnly: true + nullable: true + dismissed: + type: boolean + recipient: + type: integer + readOnly: true + required: + - content + - id + - recipient + - timestamp PasswordResetConfirm: type: object properties: @@ -581,8 +877,8 @@ components: email: type: string format: email + readOnly: true title: Email address - maxLength: 254 avatar: type: string format: uri @@ -592,6 +888,35 @@ components: last_name: type: string maxLength: 150 + user_group: + type: integer + readOnly: true + nullable: true + group_member: + type: string + readOnly: true + group_owner: + type: string + readOnly: true + PatchedNotification: + type: object + properties: + id: + type: integer + readOnly: true + timestamp: + type: string + format: date-time + readOnly: true + content: + type: string + readOnly: true + nullable: true + dismissed: + type: boolean + recipient: + type: integer + readOnly: true SendEmailReset: type: object properties: @@ -625,6 +950,30 @@ components: required: - current_password - new_username + SubscriptionPlan: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + maxLength: 100 + description: + type: string + nullable: true + maxLength: 1024 + annual_price: + type: integer + nullable: true + monthly_price: + type: integer + nullable: true + group_exclusive: + type: boolean + required: + - id + - name TokenObtainPair: type: object properties: @@ -668,32 +1017,52 @@ components: UserRegistration: type: object properties: - username: - type: string - description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ - only. - pattern: ^[\w.@+-]+$ - maxLength: 150 email: type: string format: email + username: + type: string password: type: string writeOnly: true - avatar: - type: string - format: uri - nullable: true first_name: type: string - maxLength: 150 last_name: type: string - maxLength: 150 required: - email + - first_name + - last_name - password - username + UserSubscription: + type: object + properties: + id: + type: integer + readOnly: true + user: + type: integer + nullable: true + user_group: + type: integer + nullable: true + subscription: + type: integer + nullable: true + date: + type: string + format: date-time + readOnly: true + valid: + type: boolean + annual: + type: boolean + required: + - annual + - date + - id + - valid UsernameResetConfirm: type: object properties: diff --git a/backend/subscriptions/__init__.py b/backend/subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/subscriptions/admin.py b/backend/subscriptions/admin.py new file mode 100644 index 0000000..1bbade1 --- /dev/null +++ b/backend/subscriptions/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from subscriptions.models import StripePrice, SubscriptionPlan, UserSubscription +from unfold.admin import ModelAdmin +from unfold.contrib.filters.admin import RangeDateFilter + + +@admin.register(StripePrice) +class StripePriceAdmin(ModelAdmin): + search_fields = ["id", "lookup_key", + "stripe_price_id","price","currency", "prorated", "annual"] + list_display = ["id", "lookup_key", + "stripe_price_id", "price", "currency", "prorated", "annual"] + + +@admin.register(SubscriptionPlan) +class SubscriptionPlanAdmin(ModelAdmin): + list_display = ["id", "__str__", "stripe_product_id", "group_exclusive"] + search_fields = ["id", "name", "stripe_product_id", "group_exclusive"] + + +@admin.register(UserSubscription) +class UserSubscriptionAdmin(ModelAdmin): + list_filter_submit = True + list_filter = (( + "date", RangeDateFilter + ),) + list_display = ["id", "__str__", "valid", "annual", + "date"] + search_fields = ["id", "date"] diff --git a/backend/subscriptions/apps.py b/backend/subscriptions/apps.py new file mode 100644 index 0000000..120d081 --- /dev/null +++ b/backend/subscriptions/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class SubscriptionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'subscriptions' + + def ready(self): + import subscriptions.signals diff --git a/backend/subscriptions/migrations/0001_initial.py b/backend/subscriptions/migrations/0001_initial.py new file mode 100644 index 0000000..46ddb84 --- /dev/null +++ b/backend/subscriptions/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 5.0.6 on 2024-05-10 06:37 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_groups', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StripePrice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('annual', models.BooleanField(default=False)), + ('stripe_price_id', models.CharField(max_length=100)), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), + ('currency', models.CharField(max_length=20)), + ('lookup_key', models.CharField(blank=True, max_length=100, null=True)), + ('prorated', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='SubscriptionPlan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(max_length=1024, null=True)), + ('stripe_product_id', models.CharField(max_length=100)), + ('group_exclusive', models.BooleanField(default=False)), + ('annual_price', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='annual_plan', to='subscriptions.stripeprice')), + ('monthly_price', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='monthly_plan', to='subscriptions.stripeprice')), + ], + ), + migrations.CreateModel( + name='UserSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_id', models.CharField(max_length=100)), + ('date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('valid', models.BooleanField()), + ('annual', models.BooleanField()), + ('subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='subscriptions.subscriptionplan')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('user_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='user_groups.usergroup')), + ], + ), + ] diff --git a/backend/subscriptions/migrations/__init__.py b/backend/subscriptions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/subscriptions/models.py b/backend/subscriptions/models.py new file mode 100644 index 0000000..3147c50 --- /dev/null +++ b/backend/subscriptions/models.py @@ -0,0 +1,56 @@ + +from django.db import models +from accounts.models import CustomUser +from user_groups.models import UserGroup +from django.utils.timezone import now + + +class StripePrice(models.Model): + annual = models.BooleanField(default=False) + stripe_price_id = models.CharField(max_length=100) + price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) + currency = models.CharField(max_length=20) + lookup_key = models.CharField(max_length=100, blank=True, null=True) + prorated = models.BooleanField(default=False) + + def __str__(self): + if self.annual: + return f"{self.price}{self.currency}/year" + else: + return f"{self.price}{self.currency}/month" + + +class SubscriptionPlan(models.Model): + name = models.CharField(max_length=100) + description = models.TextField(max_length=1024, null=True) + stripe_product_id = models.CharField(max_length=100) + annual_price = models.ForeignKey( + StripePrice, on_delete=models.SET_NULL, related_name='annual_plan', null=True) + monthly_price = models.ForeignKey( + StripePrice, on_delete=models.SET_NULL, related_name='monthly_plan', null=True) + group_exclusive = models.BooleanField(default=False) + + def __str__(self): + return f"{self.name}" + + +# Model for User Subscriptions + + +class UserSubscription(models.Model): + user = models.ForeignKey( + CustomUser, on_delete=models.CASCADE, blank=True, null=True) + user_group = models.ForeignKey( + UserGroup, on_delete=models.CASCADE, blank=True, null=True) + subscription = models.ForeignKey( + SubscriptionPlan, on_delete=models.SET_NULL, blank=True, null=True) + stripe_id = models.CharField(max_length=100) + date = models.DateTimeField(default=now, editable=False) + valid = models.BooleanField() + annual = models.BooleanField() + + def __str__(self): + if self.user: + return f'Subscription {self.subscription.name} for {self.user}' + else: + return f'Subscription {self.subscription.name} for {self.user_group}' diff --git a/backend/subscriptions/serializers.py b/backend/subscriptions/serializers.py new file mode 100644 index 0000000..973bbe0 --- /dev/null +++ b/backend/subscriptions/serializers.py @@ -0,0 +1,44 @@ +from rest_framework import serializers +from subscriptions.models import SubscriptionPlan, UserSubscription, StripePrice +from accounts.serializers import SimpleCustomUserSerializer + + +class SimpleStripePriceSerializer(serializers.ModelSerializer): + + class Meta: + model = StripePrice + fields = ['price', 'currency', 'prorated'] + + +class SubscriptionPlanSerializer(serializers.ModelSerializer): + + class Meta: + model = SubscriptionPlan + fields = ['id', 'name', 'description', + 'annual_price', 'monthly_price', 'group_exclusive'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['annual_price'] = SimpleStripePriceSerializer( + instance.annual_price, many=False).data + representation['monthly_price'] = SimpleStripePriceSerializer( + instance.monthly_price, many=False).data + return representation + + +class UserSubscriptionSerializer(serializers.ModelSerializer): + date = serializers.DateTimeField( + format="%m-%d-%Y %I:%M %p", read_only=True) + + class Meta: + model = UserSubscription + fields = ['id', 'user', 'user_group', 'subscription', + 'date', 'valid', 'annual'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['user'] = SimpleCustomUserSerializer( + instance.user, many=False).data + representation['subscription'] = SubscriptionPlanSerializer( + instance.subscription, many=False).data + return representation diff --git a/backend/subscriptions/signals.py b/backend/subscriptions/signals.py new file mode 100644 index 0000000..e12006b --- /dev/null +++ b/backend/subscriptions/signals.py @@ -0,0 +1,91 @@ +from django.dispatch import receiver +from django.db.models.signals import post_migrate, post_save +from .models import UserSubscription, StripePrice, SubscriptionPlan +from django.core.cache import cache +from config.settings import STRIPE_SECRET_KEY +import stripe +stripe.api_key = STRIPE_SECRET_KEY + +# Template for running actions after user have paid for a subscription + + +@receiver(post_save, sender=SubscriptionPlan) +def clear_cache_after_plan_updates(sender, instance, **kwargs): + # Clear cache + cache.delete('subscriptionplans') + + +@receiver(post_save, sender=UserSubscription) +def scan_after_payment(sender, instance, **kwargs): + # If the updated/created subscription is valid + if instance.valid and instance.user: + # TODO: Add any Celery task actions here for regular subscription payees + pass + + +@receiver(post_migrate) +def create_subscriptions(sender, **kwargs): + if sender.name == 'subscriptions': + print('Importing data from Stripe') + created_prices = 0 + created_plans = 0 + skipped_prices = 0 + skipped_plans = 0 + products = stripe.Product.list(active=True) + prices = stripe.Price.list(expand=["data.tiers"], active=True) + + # Create the StripePrice + for price in prices['data']: + annual = (price['recurring']['interval'] == + 'year') if price['recurring'] else False + STRIPE_PRICE, CREATED = StripePrice.objects.get_or_create( + stripe_price_id=price['id'], + price=price['unit_amount'] / 100, + annual=annual, + lookup_key=price['lookup_key'], + prorated=price['recurring']['usage_type'] == 'metered', + currency=price['currency'] + ) + if CREATED: + created_prices += 1 + else: + skipped_prices += 1 + + # Create the SubscriptionPlan + for product in products['data']: + ANNUAL_PRICE = None + MONTHLY_PRICE = None + for price in prices['data']: + if price['product'] == product['id']: + STRIPE_PRICE = StripePrice.objects.get( + stripe_price_id=price['id'], + ) + if STRIPE_PRICE.annual: + ANNUAL_PRICE = STRIPE_PRICE + else: + MONTHLY_PRICE = STRIPE_PRICE + if ANNUAL_PRICE or MONTHLY_PRICE: + SUBSCRIPTION_PLAN, CREATED = SubscriptionPlan.objects.get_or_create( + name=product['name'], + description=product['description'], + stripe_product_id=product['id'], + annual_price=ANNUAL_PRICE, + monthly_price=MONTHLY_PRICE, + group_exclusive=product['metadata']['group_exclusive'] == 'True' + ) + if CREATED: + created_plans += 1 + else: + skipped_plans += 1 + # Skip over plans with missing pricing rates + else: + print('Skipping plan' + + product['name'] + 'with missing pricing data') + + # Assign the StripePrice to the SubscriptionPlan + SUBSCRIPTION_PLAN.save() + + print('Created', created_plans, 'new plans') + print('Skipped', skipped_plans, 'existing plans') + print('Created', created_prices, 'new prices') + print('Skipped', skipped_prices, 'existing prices') diff --git a/backend/subscriptions/tasks.py b/backend/subscriptions/tasks.py new file mode 100644 index 0000000..e50fa6a --- /dev/null +++ b/backend/subscriptions/tasks.py @@ -0,0 +1,42 @@ +from celery import shared_task + + +@shared_task +def get_user_subscription(user_id): + from subscriptions.models import UserSubscription + from accounts.models import CustomUser + + USER = CustomUser.objects.get(id=user_id) + + # Get a list of subscriptions for the specified user + active_subscriptions = None + if USER.user_group: + active_subscriptions = UserSubscription.objects.filter( + user_group=USER.user_group, valid=True) + else: + active_subscriptions = UserSubscription.objects.filter( + user=USER, valid=True) + + # Return first valid subscription if there is one + if len(active_subscriptions) > 0: + return active_subscriptions[0] + else: + return None + + +@shared_task +def get_user_group_subscription(user_group): + from subscriptions.models import UserSubscription + from user_groups.models import UserGroup + + USER_GROUP = UserGroup.objects.get(id=user_group) + # Get a list of subscriptions for the specified user + active_subscriptions = None + active_subscriptions = UserSubscription.objects.filter( + user_group=USER_GROUP, valid=True) + + # Return first valid subscription if there is one + if len(active_subscriptions) > 0: + return active_subscriptions[0] + else: + return None diff --git a/backend/subscriptions/urls.py b/backend/subscriptions/urls.py new file mode 100644 index 0000000..0a8afe1 --- /dev/null +++ b/backend/subscriptions/urls.py @@ -0,0 +1,14 @@ +from django.urls import path, include +from subscriptions import views +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register(r'plans', views.SubscriptionPlanViewset, + basename="Subscription Plans") +router.register(r'self', views.UserSubscriptionViewset, + basename="Self Subscriptions") +router.register(r'user_group', views.UserGroupSubscriptionViewet, + basename="Group Subscriptions") +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/subscriptions/views.py b/backend/subscriptions/views.py new file mode 100644 index 0000000..4cfe9aa --- /dev/null +++ b/backend/subscriptions/views.py @@ -0,0 +1,56 @@ +from subscriptions.serializers import SubscriptionPlanSerializer, UserSubscriptionSerializer +from subscriptions.models import SubscriptionPlan, UserSubscription +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework import viewsets +from django.core.cache import cache + + +class SubscriptionPlanViewset(viewsets.ModelViewSet): + http_method_names = ['get'] + serializer_class = SubscriptionPlanSerializer + permission_classes = [AllowAny] + queryset = SubscriptionPlan.objects.all() + + def get_queryset(self): + key = 'subscriptionplans' + queryset = cache.get(key) + if not queryset: + queryset = super().get_queryset() + cache.set(key, queryset, 60*60) + return queryset + + +class UserSubscriptionViewset(viewsets.ModelViewSet): + http_method_names = ['get'] + serializer_class = UserSubscriptionSerializer + permission_classes = [IsAuthenticated] + queryset = UserSubscription.objects.all() + + def get_queryset(self): + user = self.request.user + key = f'subscriptions_user:{user.id}' + queryset = cache.get(key) + if not queryset: + queryset = UserSubscription.objects.filter(user=user) + cache.set(key, queryset, 60*60) + return queryset + + +class UserGroupSubscriptionViewet(viewsets.ModelViewSet): + http_method_names = ['get'] + serializer_class = UserSubscriptionSerializer + permission_classes = [IsAuthenticated] + queryset = UserSubscription.objects.all() + + def get_queryset(self): + user = self.request.user + if not user.user_group: + return UserSubscription.objects.none() + else: + key = f'subscriptions_usergroup:{user.user_group.id}' + queryset = cache.get(key) + if not cache: + queryset = UserSubscription.objects.filter( + user_group=user.user_group) + cache.set(key, queryset, 60*60) + return queryset diff --git a/backend/user_groups/__init__.py b/backend/user_groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/user_groups/admin.py b/backend/user_groups/admin.py new file mode 100644 index 0000000..76ed323 --- /dev/null +++ b/backend/user_groups/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin +from .models import UserGroup +from unfold.contrib.filters.admin import RangeDateFilter + + +@admin.register(UserGroup) +class UserGroupAdmin(ModelAdmin): + list_filter_submit = True + list_filter = (( + "date_created", RangeDateFilter + ),) + + list_display = ['id', 'name'] + search_fields = ['id', 'name'] diff --git a/backend/user_groups/apps.py b/backend/user_groups/apps.py new file mode 100644 index 0000000..85751fe --- /dev/null +++ b/backend/user_groups/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class EnterpriseGroupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "user_groups" + + def ready(self): + import user_groups.signals diff --git a/backend/user_groups/migrations/0001_initial.py b/backend/user_groups/migrations/0001_initial.py new file mode 100644 index 0000000..d0d63ea --- /dev/null +++ b/backend/user_groups/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-05-10 06:37 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='UserGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('date_created', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ], + ), + ] diff --git a/backend/user_groups/migrations/0002_usergroup_managers_usergroup_members_usergroup_owner.py b/backend/user_groups/migrations/0002_usergroup_managers_usergroup_members_usergroup_owner.py new file mode 100644 index 0000000..a77e36e --- /dev/null +++ b/backend/user_groups/migrations/0002_usergroup_managers_usergroup_members_usergroup_owner.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.6 on 2024-05-10 06:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_groups', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='usergroup', + name='managers', + field=models.ManyToManyField(related_name='usergroup_managers', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='usergroup', + name='members', + field=models.ManyToManyField(related_name='usergroup_members', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='usergroup', + name='owner', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usergroup_owner', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/user_groups/migrations/__init__.py b/backend/user_groups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/user_groups/models.py b/backend/user_groups/models.py new file mode 100644 index 0000000..e62d767 --- /dev/null +++ b/backend/user_groups/models.py @@ -0,0 +1,24 @@ +from django.db import models +from django.utils.timezone import now +from config.settings import STRIPE_SECRET_KEY +import stripe +stripe.api_key = STRIPE_SECRET_KEY + + +class UserGroup(models.Model): + name = models.CharField(max_length=128, null=False) + owner = models.ForeignKey( + 'accounts.CustomUser', on_delete=models.SET_NULL, null=True, related_name='usergroup_owner') + managers = models.ManyToManyField( + 'accounts.CustomUser', related_name='usergroup_managers') + members = models.ManyToManyField( + 'accounts.CustomUser', related_name='usergroup_members') + date_created = models.DateTimeField(default=now, editable=False) + + # Derived from email of owner, may be used for billing + @property + def email(self): + return self.owner.email + + def __str__(self): + return self.name diff --git a/backend/user_groups/serializers.py b/backend/user_groups/serializers.py new file mode 100644 index 0000000..a1d105f --- /dev/null +++ b/backend/user_groups/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import UserGroup + + +class SimpleUserGroupSerializer(serializers.ModelSerializer): + date_created = serializers.DateTimeField( + format="%m-%d-%Y %I:%M %p", read_only=True) + + class Meta: + model = UserGroup + fields = ['id', 'name', 'date_created'] + read_only_fields = ['id', 'name', 'date_created'] diff --git a/backend/user_groups/signals.py b/backend/user_groups/signals.py new file mode 100644 index 0000000..fc22c56 --- /dev/null +++ b/backend/user_groups/signals.py @@ -0,0 +1,107 @@ +from subscriptions.models import SubscriptionPlan +from accounts.models import CustomUser +from .models import UserGroup +from subscriptions.tasks import get_user_group_subscription +from django.db.models.signals import m2m_changed, post_migrate +from django.dispatch import receiver +from config.settings import STRIPE_SECRET_KEY, ROOT_DIR +import os +import json +import stripe +stripe.api_key = STRIPE_SECRET_KEY + + +@receiver(m2m_changed, sender=UserGroup.managers.through) +def update_group_managers(sender, instance, action, **kwargs): + # When adding new managers to a UserGroup, associate them with it + if action == 'post_add': + # Get the newly added managers + new_managers = kwargs.get('pk_set', set()) + for manager in new_managers: + # Retrieve the member + USER = CustomUser.objects.get(pk=manager) + if not USER.user_group: + # Update their group assiociation + USER.user_group = instance + USER.save() + if USER not in instance.members.all(): + instance.members.add(USER) + # When removing managers from a UserGroup, remove their association with it + elif action == 'post_remove': + for manager in kwargs['pk_set']: + # Retrieve the manager + USER = CustomUser.objects.get(pk=manager) + if USER not in instance.members.all(): + USER.user_group = None + USER.save() + + +@receiver(m2m_changed, sender=UserGroup.members.through) +def update_group_members(sender, instance, action, **kwargs): + # When adding new members to a UserGroup, associate them with it + if action == 'post_add': + # Get the newly added members + new_members = kwargs.get('pk_set', set()) + for member in new_members: + # Retrieve the member + USER = CustomUser.objects.get(pk=member) + if not USER.user_group: + # Update their group assiociation + USER.user_group = instance + USER.save() + # When removing members from a UserGroup, remove their association with it + elif action == 'post_remove': + for client in kwargs['pk_set']: + USER = CustomUser.objects.get(pk=client) + if USER not in instance.members.all() and USER not in instance.managers.all(): + USER.user_group = None + USER.save() + # Update usage records + SUBSCRIPTION_GROUP = get_user_group_subscription(instance.id) + if SUBSCRIPTION_GROUP: + try: + print(f"Updating usage record for UserGroup {instance.name}") + # Update usage for members + SUBSCRIPTION_ITEM = SUBSCRIPTION_GROUP.subscription + stripe.SubscriptionItem.create_usage_record( + SUBSCRIPTION_ITEM.stripe_id, + quantity=len(instance.members.all()), + action="set" + ) + except: + print( + f'Warning: Unable to update usage record for SubscriptionGroup ID:{instance.id}') + + +@receiver(post_migrate) +def create_groups(sender, **kwargs): + if sender.name == "agencies": + with open(os.path.join(ROOT_DIR, 'seed_data.json'), "r") as f: + seed_data = json.loads(f.read()) + for user_group in seed_data['user_groups']: + OWNER = CustomUser.objects.filter( + email=user_group['owner']).first() + USER_GROUP, CREATED = UserGroup.objects.get_or_create( + owner=OWNER, + agency_name=user_group['name'], + ) + if CREATED: + print(f"Created UserGroup {USER_GROUP.agency_name}") + + # Add managers + USERS = CustomUser.objects.filter( + email__in=user_group['managers']) + for USER in USERS: + if USER not in USER_GROUP.managers.all(): + print( + f"Adding User {USER.full_name} as manager to UserGroup {USER_GROUP.agency_name}") + USER_GROUP.managers.add(USER) + # Add members + USERS = CustomUser.objects.filter( + email__in=user_group['members']) + for USER in USERS: + if USER not in USER_GROUP.members.all(): + print( + f"Adding User {USER.full_name} as member to UserGroup {USER_GROUP.agency_name}") + USER_GROUP.clients.add(USER) + USER_GROUP.save() diff --git a/backend/webdriver/__init__.py b/backend/webdriver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/webdriver/apps.py b/backend/webdriver/apps.py new file mode 100644 index 0000000..2df962c --- /dev/null +++ b/backend/webdriver/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EmailsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'webdriver' diff --git a/backend/webdriver/tasks.py b/backend/webdriver/tasks.py new file mode 100644 index 0000000..a2901c7 --- /dev/null +++ b/backend/webdriver/tasks.py @@ -0,0 +1,16 @@ +from celery import shared_task +from webdriver.utils import setup_webdriver, selenium_action_template + +# Sample Celery Selenium function +# TODO: Modify this as needed + + +@shared_task(autoretry_for=(Exception,), retry_kwargs={'max_retries': 6, 'countdown': 5}) +def sample_selenium_task(): + driver = setup_webdriver() + selenium_action_template(driver) + # Place any other actions here after Selenium is done executing + + # Once completed, always close the session + driver.close() + driver.quit() diff --git a/backend/webdriver/utils.py b/backend/webdriver/utils.py new file mode 100644 index 0000000..24ea142 --- /dev/null +++ b/backend/webdriver/utils.py @@ -0,0 +1,390 @@ +""" +Settings file to hold constants and functions +""" +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +import os +from config.settings import get_secret +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver import FirefoxOptions +from selenium import webdriver +import undetected_chromedriver as uc +from config.settings import USE_PROXY, CAPTCHA_TESTING +from config.settings import get_secret +from twocaptcha import TwoCaptcha +from whois import whois +from whois.parser import PywhoisError + + +def take_snapshot(driver, filename='dump.png'): + # Set window size + required_width = driver.execute_script( + 'return document.body.parentNode.scrollWidth') + required_height = driver.execute_script( + 'return document.body.parentNode.scrollHeight') + driver.set_window_size( + required_width, required_height+(required_height*0.05)) + + # Take the snapshot + driver.find_element(By.TAG_NAME, + 'body').screenshot('/dumps/'+filename) # avoids any scrollbars + print('Snapshot saved') + + +def dump_html(driver, filename='dump.html'): + # Save the page source to error.html + with open(('/dumps/'+filename), 'w', encoding='utf-8') as file: + file.write(driver.page_source) + + +def setup_webdriver(driver_type="chrome", use_proxy=True, use_saved_session=False): + # Manual proxy override via .env variable + if not USE_PROXY: + use_proxy = False + if use_proxy: + print('Running driver with proxy enabled') + else: + print('Running driver with proxy disabled') + + if use_saved_session: + print('Running with saved session') + else: + print('Running without using saved session') + + if driver_type == "chrome": + print('Using Chrome driver') + opts = uc.ChromeOptions() + + if use_saved_session: + if os.path.exists("/tmp_chrome_profile"): + print('Existing Chrome ephemeral profile found') + else: + print('No existing Chrome ephemeral profile found') + os.system("mkdir /tmp_chrome_profile") + if os.path.exists('/chrome'): + print('Copying Chrome Profile to ephemeral directory') + # Flush any non-essential cache directories from the existing profile as they may balloon in size overtime + os.system( + 'rm -rf "/chrome/Selenium Profile/Code Cache/*"') + # Create a copy of the Chrome Profile + os.system("cp -r /chrome/* /tmp_chrome_profile") + try: + # Remove some items related to file locks + os.remove('/tmp_chrome_profile/SingletonLock') + os.remove('/tmp_chrome_profile/SingletonSocket') + os.remove('/tmp_chrome_profile/SingletonLock') + except: + pass + else: + print('No existing Chrome Profile found. Creating one from scratch') + + if use_saved_session: + # Specify the user data directory + opts.add_argument(f'--user-data-dir=/tmp_chrome_profile') + opts.add_argument('--profile-directory=Selenium Profile') + + # Set proxy + if use_proxy: + opts.add_argument( + f'--proxy-server=socks5://{get_secret("PROXY_IP")}:{get_secret("PROXY_PORT_IP_AUTH")}') + + opts.add_argument("--disable-extensions") + opts.add_argument('--disable-application-cache') + opts.add_argument("--disable-setuid-sandbox") + opts.add_argument('--disable-dev-shm-usage') + opts.add_argument("--disable-gpu") + opts.add_argument("--no-sandbox") + opts.add_argument("--headless=new") + driver = uc.Chrome(options=opts) + + elif driver_type == "firefox": + print('Using firefox driver') + opts = FirefoxOptions() + if use_saved_session: + if not os.path.exists("/firefox"): + print('No profile found') + os.makedirs("/firefox") + else: + print('Existing profile found') + # Specify a profile if it exists + opts.profile = "/firefox" + + # Set proxy + if use_proxy: + opts.set_preference('network.proxy.type', 1) + opts.set_preference('network.proxy.socks', + get_secret('PROXY_IP')) + opts.set_preference('network.proxy.socks_port', + int(get_secret('PROXY_PORT_IP_AUTH'))) + opts.set_preference('network.proxy.socks_remote_dns', False) + + opts.add_argument('--disable-dev-shm-usage') + opts.add_argument("--headless") + opts.add_argument("--disable-gpu") + driver = webdriver.Firefox(options=opts) + + driver.maximize_window() + + # Check if proxy is working + driver.get('https://api.ipify.org/') + body = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.TAG_NAME, "body"))) + ip_address = body.text + print(f'External IP: {ip_address}') + return driver + +# Function to get the element once it has loaded in + + +def get_element(driver, by, key, hidden_element=False, timeout=8): + try: + if by == "xpath": + by = By.XPATH + elif by == "css": + by = By.CSS_SELECTOR + elif by == "id": + by = By.ID + elif by == "tagname": + by = By.TAG_NAME + elif by == "name": + by = By.NAME + elif by == "classname": + by == By.CLASS_NAME + wait = WebDriverWait(driver, timeout=timeout) + if not hidden_element: + element = wait.until( + EC.element_to_be_clickable((by, key)) and EC.visibility_of_element_located((by, key))) + else: + element = wait.until(EC.presence_of_element_located( + (by, key))) + return element + except Exception: + raise Exception(f"Unable to get element of {by} value: {key}") + + +def execute_selenium_elements(driver, timeout, elements): + try: + for index, element in enumerate(elements): + print('Waiting...') + # Element may have a keyword specified, check if that exists before running any actions + if "keyword" in element: + # Skip a step if the keyword does not exist + if element['keyword'] not in driver.page_source: + print( + f'Keyword {element["keyword"]} does not exist. Skipping step: {index+1} - {element["name"]}') + continue + elif element['keyword'] in driver.page_source and element['type'] == 'skip': + print( + f'Keyword {element["keyword"]} does exists. Stopping at step: {index+1} - {element["name"]}') + break + print(f'Step: {index+1} - {element["name"]}') + # Revert to default iframe action + if element["type"] == "revert_default_frame": + driver.switch_to.default_content() + continue + # CAPTCHA Callback + elif element["type"] == "recaptchav2_callback": + if callable(element["input"]): + values = element["input"]() + else: + values = element["input"] + if type(values) is list: + raise Exception( + 'Invalid input value specified for "callback" type') + else: + # For single input values + driver.execute_script( + f'onRecaptcha("{values}");') + continue + try: + # Try to get default element + if "hidden" in element: + site_element = get_element( + driver, element["default"]["type"], element["default"]["key"], hidden_element=True, timeout=timeout) + else: + site_element = get_element( + driver, element["default"]["type"], element["default"]["key"], timeout=timeout) + except Exception as e: + print(f'Failed to find primary element') + # If that fails, try to get the failover one + print('Trying to find legacy element') + if "hidden" in element: + site_element = get_element( + driver, element["failover"]["type"], element["failover"]["key"], hidden_element=True, timeout=timeout) + else: + site_element = get_element( + driver, element["failover"]["type"], element["failover"]["key"], timeout=timeout) + # Clicking an element + if element["type"] == "click": + site_element.click() + # Switching to an element frame/iframe + elif element["type"] == "switch_to_iframe_click": + driver.switch_to.frame(site_element) + # Input type simulates user typing + elif element["type"] == "input": + if callable(element["input"]): + values = element["input"]() + else: + values = element["input"] + values = values.splitlines() + + # For multiple input values + for index, value in enumerate(values): + site_element.send_keys(value) + # Only send a new line keypress if this is not the last value to enter in the list + if index != len(values) - 1: + site_element.send_keys(Keys.RETURN) + elif element["type"] == "input_enter": + site_element.send_keys(Keys.RETURN) + # Input_replace type places values directly. Useful for CAPTCHA + elif element["type"] == "input_replace": + if callable(element["input"]): + values = element["input"]() + else: + values = element["input"] + if type(values) is list: + raise Exception( + 'Invalid input value specified for "input_replace" type') + else: + # For single input values + driver.execute_script( + f'arguments[0].value = "{values}";', site_element) + except Exception as e: + take_snapshot(driver) + dump_html(driver) + driver.close() + driver.quit() + raise Exception(e) + + +def solve_captcha(site_key, url, retry_attempts=3, version='v2', enterprise=False, use_proxy=True): + # Manual proxy override set via $ENV + if not USE_PROXY: + use_proxy = False + if CAPTCHA_TESTING: + print('Initializing CAPTCHA solver in dummy mode') + code = "12345" + print("CAPTCHA Successful") + return code + + elif use_proxy: + print('Using CAPTCHA solver with proxy') + else: + print('Using CAPTCHA solver without proxy') + + captcha_params = { + "url": url, + "sitekey": site_key, + "version": version, + "enterprise": 1 if enterprise else 0, + "proxy": { + 'type': 'socks5', + 'uri': get_secret('PROXY_USER_AUTH') + } if use_proxy else None + } + + # Keep retrying until max attempts is reached + for _ in range(retry_attempts): + # Solver uses 2CAPTCHA by default + solver = TwoCaptcha(get_secret("CAPTCHA_API_KEY")) + try: + print('Waiting for CAPTCHA code...') + code = solver.recaptcha(**captcha_params)["code"] + print("CAPTCHA Successful") + return code + except Exception as e: + print(f'CAPTCHA Failed! {e}') + + raise Exception(f"CAPTCHA API Failed!") + + +def whois_lookup(url): + try: + lookup_info = whois(url) + # TODO: Add your own processing here + except PywhoisError: + print(f"No WhoIs record found for {url}") + return lookup_info + + +def save_browser_session(driver): + # Copy over the profile once we finish logging in + if isinstance(driver, webdriver.Firefox): + # Copy process for Firefox + print('Updating saved Firefox profile') + # Get the current profile directory from about:support page + driver.get("about:support") + box = get_element( + driver, "id", "profile-dir-box", timeout=4) + temp_profile_path = os.path.join(os.getcwd(), box.text) + profile_path = '/firefox' + # Create the command + copy_command = "cp -r " + temp_profile_path + "/* " + profile_path + # Copy over the Firefox profile + if os.system(copy_command): + print("Firefox profile saved") + elif isinstance(driver, uc.Chrome): + # Copy the Chrome profile + print('Updating non-ephemeral Chrome profile') + # Flush Code Cache again to speed up copy + os.system( + 'rm -rf "/tmp_chrome_profile/SimpleDMCA Profile/Code Cache/*"') + if os.system("cp -r /tmp_chrome_profile/* /chrome"): + print("Chrome profile saved") + +# Sample function +# Call this within a Celery task +# TODO: Modify as needed to your needs + + +def selenium_action_template(driver): + info = { + "sample_field1": "sample_data", + "sample_field2": "sample_data", + "captcha_code": lambda: solve_captcha('SITE_KEY', 'SITE_URL') + } + + elements = [ + { + "name": "Enter data for sample field 1", + "type": "input", + "input": "{first_name}", + # If a site implements canary design releases, you can place the ID for the element in the new design + "default": { + # See get_element() for possible selector types + "type": "xpath", + "key": '' + }, + # If a site implements canary design releases, you can place the ID for the element in the old design here + "failover": { + "type": "xpath", + "key": '' + } + }, + ] + + # Dictionary to store values which will be entered via Selenium + # Helps prevent duplicates and stale values compared to just using the info variable directly + site_form_values = {} + + # Fill in final fstring values in elements + for element in elements: + if 'input' in element and '{' in element['input']: + a = element['input'].strip('{}') + if a in info: + value = info[a] + # Check if the value is a callable (a lambda function) and call it if so + if callable(value): + # Check if the value has already been called + if a not in site_form_values: + # Call the value and store it in the dictionary + site_form_values[a] = value() + # Use the stored value + value = site_form_values[a] + # Replace the placeholder with the actual value + element['input'] = str(value) + + # Execute the selenium actions + execute_selenium_elements(driver, 8, elements) diff --git a/docker-compose.yml b/docker-compose.yml index bc33836..419abf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,115 @@ version: "3.9" services: - # Django App - django_backend: + # Django Backend + # http://localhost:8000 + django: + env_file: .env build: context: . dockerfile: Dockerfile - image: test_backend:latest + image: drf_template:latest ports: - - "8092:8000" + - "8000:8000" environment: - PYTHONBUFFERED=1 - command: - [ - "sh", - "-c", - "python backend/manage.py spectacular --color --file backend/schema.yml && python backend/manage.py collectstatic --noinput && python backend/manage.py makemigrations && python backend/manage.py migrate && python backend/manage.py runserver 127.0.0.1:8000", - ] + - RUN_TYPE=web volumes: - - .:/code # For hotreloading + - .:/code + depends_on: + - postgres + + # Django Celery Worker + celery: + env_file: .env + environment: + - RUN_TYPE=worker + image: drf_template:latest + volumes: + - .:/code + - ./chrome:/chrome + - ./firefox:/firefox + - ./dumps:/dumps + depends_on: + - django + - postgres + - redis + ## Runs multiple worker instances + scale: 4 + + # Django Celery Beat + celery_beat: + env_file: .env + environment: + - RUN_TYPE=beat + image: drf_template:latest + volumes: + - .:/code + depends_on: + - celery + - django + - postgres + - redis + + # Django Celery Monitor + # http://localhost:5000 + celery_monitor: + env_file: .env + environment: + - RUN_TYPE=monitor + image: drf_template:latest + ports: + - "5555:5555" + volumes: + - .:/code + depends_on: + - celery + - django + - redis + + # SQL Database + postgres: + env_file: .env + image: postgres + environment: + - POSTGRES_DB=${DB_DATABASE} + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + volumes: + - db-data:/var/lib/postgresql/data + + # Redis Server + redis: + image: redis:latest + ports: + - "${REDIS_PORT}:6379" + + # Stripe CLI Webhook Listener + stripe-listener: + env_file: .env + image: stripe/stripe-cli:latest + environment: + - STRIPE_WEBHOOK_SECRET=${STRIPE_SECRET_WEBHOOK} + - STRIPE_API_KEY=${STRIPE_SECRET_KEY} + command: listen --forward-to django:8000/api/v1/stripe/webhook/ + + # Email Testing Server + # http://localhost:8025 + inbucket: + image: inbucket/inbucket:latest + ports: + - "8025:8025" + - "1025:1025" + environment: + - INBUCKET_LOGLEVEL=error + - INBUCKET_MAILBOXNAMING=domain + - INBUCKET_SMTP_ADDR=0.0.0.0:1025 + - INBUCKET_SMTP_MAXRECIPIENTS=1000 + - INBUCKET_WEB_ADDR=0.0.0.0:8025 + - INBUCKET_STORAGE_TYPE=memory + - INBUCKET_STORAGE_MAILBOXMSGCAP=2000 volumes: - test_backend: + db-data: diff --git a/documentation/erd/app_models.png b/documentation/erd/app_models.png new file mode 100644 index 0000000000000000000000000000000000000000..ca31db6bce92e0a5f3b827f2b9ceedbb9329cb45 GIT binary patch literal 151473 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index 64f1923..a78020f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,66 +1,109 @@ --i https://pypi.org/simple -annotated-types==0.6.0; python_version >= '3.8' -asgiref==3.7.2; python_version >= '3.7' -attrs==23.2.0; python_version >= '3.7' -certifi==2023.11.17; python_version >= '3.6' -cffi==1.16.0; python_version >= '3.8' -charset-normalizer==3.3.2; python_full_version >= '3.7.0' -click==8.1.7; python_version >= '3.7' -colorama==0.4.6; platform_system == 'Windows' -cryptography==41.0.7; python_version >= '3.7' -defusedxml==0.8.0rc2; python_version >= '3.6' -django==5.0.1 -django-cors-headers==4.3.1 -django-extra-fields==3.0.2 -django-resized==1.0.2 -django-simple-history==3.4.0 -django-templated-mail==1.1.1 -django-unfold==0.18.1 -djangorestframework==3.14.0 -djangorestframework-simplejwt==5.3.1; python_version >= '3.8' -djoser==2.2.2 -dotty-dict==1.3.1; python_version >= '3.5' and python_version < '4.0' -drf-spectacular[sidecar]==0.27.0 -drf-spectacular-sidecar==2024.1.1 -gitdb==4.0.11; python_version >= '3.7' -gitpython==3.1.40; python_version >= '3.7' -idna==3.6; python_version >= '3.5' -importlib-resources==6.1.1; python_version >= '3.8' -inflection==0.5.1; python_version >= '3.5' -jinja2==3.1.2; python_version >= '3.7' -jsonschema==4.20.0; python_version >= '3.8' -jsonschema-specifications==2023.12.1; python_version >= '3.8' -markdown-it-py==3.0.0; python_version >= '3.8' -markupsafe==2.1.3; python_version >= '3.7' -mdurl==0.1.2; python_version >= '3.7' -oauthlib==3.2.2; python_version >= '3.6' -pillow==10.2.0 -psycopg2==2.9.9 -pycparser==2.21 -pydantic==2.5.3; python_version >= '3.7' -pydantic-core==2.14.6; python_version >= '3.7' -pygments==2.17.2; python_version >= '3.7' -pyjwt==2.8.0; python_version >= '3.7' -python-dotenv==1.0.0 -python-gitlab==4.3.0; python_full_version >= '3.8.0' -python-semantic-release==8.7.0; python_version >= '3.7' -python3-openid==3.2.0 -pytz==2023.3.post1 -pyyaml==6.0.1; python_version >= '3.6' -referencing==0.32.1; python_version >= '3.8' -requests==2.31.0; python_version >= '3.7' -requests-oauthlib==1.3.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -requests-toolbelt==1.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -rich==13.7.0; python_full_version >= '3.7.0' -rpds-py==0.16.2; python_version >= '3.8' -shellingham==1.5.4; python_version >= '3.7' -smmap==5.0.1; python_version >= '3.7' -social-auth-app-django==5.4.0; python_version >= '3.8' -social-auth-core==4.5.1; python_version >= '3.8' -sqlparse==0.4.4; python_version >= '3.5' -tomlkit==0.12.3; python_version >= '3.7' -typing-extensions==4.9.0; python_version >= '3.8' -tzdata==2023.4; sys_platform == 'win32' -uritemplate==4.1.1; python_version >= '3.6' -urllib3==2.1.0; python_version >= '3.8' -whitenoise==6.6.0 +-i https://pypi.org/simple +2captcha-python==1.2.5 +amqp==5.2.0; python_version >= '3.6' +asgiref==3.8.1; python_version >= '3.8' +async-timeout==4.0.3; python_full_version < '3.11.3' +attrs==23.2.0; python_version >= '3.7' +autobahn==23.6.2; python_version >= '3.9' +automat==22.10.0 +autopep8==2.1.0; python_version >= '3.8' +billiard==4.2.0; python_version >= '3.7' +celery==5.4.0 +certifi==2024.2.2; python_version >= '3.6' +cffi==1.16.0; +charset-normalizer==3.3.2; python_full_version >= '3.7.0' +click==8.1.7; python_version >= '3.7' +click-didyoumean==0.3.1; python_full_version >= '3.6.2' +click-plugins==1.1.1 +click-repl==0.3.0; python_version >= '3.6' +colorama==0.4.6; +constantly==23.10.4; python_version >= '3.8' +cron-descriptor==1.4.3 +cryptography==42.0.7; python_version >= '3.7' +defusedxml==0.8.0rc2; python_version >= '3.6' +django==5.0.6 +django-celery-beat==2.6.0 +django-celery-results==2.5.1 +django-cors-headers==4.3.1 +django-extensions==3.2.3 +django-extra-fields==3.0.2 +django-redis==5.4.0 +django-resized==1.0.2 +django-silk==5.1.0 +django-simple-history==3.5.0 +django-storages==1.14.3 +django-templated-mail==1.1.1 +django-timezone-field==6.1.0; python_version >= '3.8' and python_version < '4.0' +django-unfold==0.22.0 +djangorestframework==3.15.1 +djangorestframework-simplejwt==5.3.1; python_version >= '3.8' +djoser==2.2.2 +drf-spectacular[sidecar]==0.27.2 +drf-spectacular-sidecar==2024.5.1 +flower==2.0.1 +gprof2dot==2022.7.29; python_version >= '2.7' +gunicorn==22.0.0 +h11==0.14.0; python_version >= '3.7' +humanize==4.9.0; python_version >= '3.8' +hyperlink==21.0.0 +idna==3.7; python_version >= '3.5' +incremental==22.10.0 +inflection==0.5.1; python_version >= '3.5' +jsonschema==4.22.0; python_version >= '3.8' +jsonschema-specifications==2023.12.1; python_version >= '3.8' +kombu==5.3.7 +msgpack==1.0.8; python_version >= '3.8' +oauthlib==3.2.2; python_version >= '3.6' +outcome==1.3.0.post0; python_version >= '3.7' +packaging==24.0; python_version >= '3.7' +pillow==10.3.0 +prometheus-client==0.20.0; python_version >= '3.8' +prompt-toolkit==3.0.43; python_full_version >= '3.7.0' +psycopg2==2.9.9 +pyasn1==0.6.0; python_version >= '3.8' +pyasn1-modules==0.4.0; python_version >= '3.8' +pycodestyle==2.11.1; python_version >= '3.8' +pycparser==2.22; python_version >= '3.8' +pygraphviz==1.13 +pyjwt==2.8.0; python_version >= '3.7' +pyopenssl==24.1.0 +pysocks==1.7.1 +python-crontab==3.0.0 +python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +python-dotenv==1.0.1 +python-whois==0.9.4 +python3-openid==3.2.0 +pytz==2024.1 +pyyaml==6.0.1; python_version >= '3.6' +redis==5.0.4 +referencing==0.35.1; python_version >= '3.8' +requests==2.31.0; python_version >= '3.7' +requests-oauthlib==2.0.0; python_version >= '3.4' +rpds-py==0.18.1; python_version >= '3.8' +selenium==4.20.0 +service-identity==24.1.0 +setuptools==69.5.1; python_version >= '3.8' +six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +sniffio==1.3.1; python_version >= '3.7' +social-auth-app-django==5.4.1; python_version >= '3.8' +social-auth-core==4.5.4; python_version >= '3.8' +sortedcontainers==2.4.0 +sqlparse==0.5.0; python_version >= '3.8' +stripe==9.6.0 +tornado==6.4; python_version >= '3.8' +trio==0.25.0; python_version >= '3.8' +trio-websocket==0.11.1; python_version >= '3.7' +twisted[tls]==24.3.0; python_full_version >= '3.8.0' +twisted-iocpsupport==1.0.4; platform_system == 'Windows' +txaio==23.1.1; python_version >= '3.7' +typing-extensions==4.11.0; python_version >= '3.8' +tzdata==2024.1; python_version >= '2' +undetected-chromedriver==3.5.5 +uritemplate==4.1.1; python_version >= '3.6' +urllib3[socks]==2.2.1; python_version >= '3.8' +vine==5.1.0; python_version >= '3.6' +wcwidth==0.2.13 +websockets==12.0; python_version >= '3.8' +whitenoise==6.6.0 +wsproto==1.2.0; python_full_version >= '3.7.0' +zope-interface==6.3; python_version >= '3.7' diff --git a/seed_data.json b/seed_data.json new file mode 100644 index 0000000..0371139 --- /dev/null +++ b/seed_data.json @@ -0,0 +1,97 @@ +{ + "schedules": [ + { + "type": "crontab", + "minute": "0", + "hour": "0", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "Asia/Manila" + }, + { + "type": "crontab", + "minute": "0", + "hour": "1", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "Asia/Manila" + }, + { + "type": "crontab", + "minute": "0", + "hour": "12", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "Asia/Manila" + }, + { + "type": "crontab", + "minute": "0", + "hour": "13", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "Asia/Manila" + } + ], + "scheduled_tasks": [ + { + "name": "Delete notifications older than 3 days every 1 AM", + "task": "notifications.tasks.cleanup_notifications", + "schedule": { + "type": "crontab", + "minute": "0", + "hour": "1", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "Asia/Manila" + }, + "enabled": true + } + ], + "users": [ + { + "username": "drf-template_admin", + "email": "admin@drf-template.com", + "password": "USE_ADMIN", + "is_superuser": true, + "first_name": "DRF-Template", + "last_name": "Admin" + }, + { + "username": "drf-template_testuser1", + "email": "testuser1@drf-template.com", + "password": "USE_REGULAR", + "is_superuser": false, + "first_name": "DRF-Template", + "last_name": "Test User 1" + }, + { + "username": "drf-template_testuser2", + "email": "testuser2@drf-template.com", + "password": "USE_REGULAR", + "is_superuser": false, + "first_name": "DRF-Template", + "last_name": "Test User 2" + }, + { + "username": "drf-template_testuser3", + "email": "testuser3@drf-template.com", + "password": "USE_REGULAR", + "is_superuser": false, + "first_name": "DRF-Template", + "last_name": "Test User 3" + } + ], + "user_groups": [ + { + "name": "DRF-Template Test Group", + "managers": "drf-template_testuser2", + "members": ["drf-template_testuser3"] + } + ] +} diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..2ff8015 --- /dev/null +++ b/start.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +echo "Running as: $RUN_TYPE" +if [ "$RUN_TYPE" = "web" ]; then + python backend/manage.py spectacular --color --file backend/schema.yml + python backend/manage.py migrate + if [ ! -d "backend/static" ]; then + echo "Generating static files" + python backend/manage.py collectstatic --noinput + fi + python backend/manage.py graph_models -o documentation/erd/app_models.png + cd backend + # python manage.py runserver 0.0.0.0:8000 + python -m gunicorn --bind 0.0.0.0:8000 -w 4 config.wsgi:application +elif [ "$RUN_TYPE" = "worker" ]; then + cd backend && celery -A config worker -l INFO -E --concurrency 1 +elif [ "$RUN_TYPE" = "beat" ]; then + sleep 15 + cd backend && celery -A config beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler +elif [ "$RUN_TYPE" = "monitor" ]; then + cd backend && celery -A config flower --port=5555 +else + echo "No RUN_TYPE value set. Defaulting to web mode" + echo "No value specified, defaulting to web" + python backend/manage.py spectacular --color --file backend/schema.yml + python backend/manage.py migrate + if [ ! -d "backend/static" ]; then + echo "Generating static files" + python backend/manage.py collectstatic --noinput + fi + python backend/manage.py graph_models -o documentation/erd/app_models.png + cd backend + # python manage.py runserver 0.0.0.0:8000 + python -m gunicorn --bind 0.0.0.0:8000 -w 4 config.wsgi:application +fi \ No newline at end of file