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 0000000..ca31db6 Binary files /dev/null and b/documentation/erd/app_models.png differ 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