diff --git a/.devcontainer/bashrc.override.sh b/.devcontainer/bashrc.override.sh new file mode 100644 index 00000000..bedddf64 --- /dev/null +++ b/.devcontainer/bashrc.override.sh @@ -0,0 +1,20 @@ + +# +# .bashrc.override.sh +# + +# persistent bash history +HISTFILE=~/.bash_history +PROMPT_COMMAND="history -a; $PROMPT_COMMAND" + +# set some django env vars +source /entrypoint + +# restore default shell options +set +o errexit +set +o pipefail +set +o nounset + +# start ssh-agent +# https://code.visualstudio.com/docs/remote/troubleshooting +eval "$(ssh-agent -s)" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..7a03274d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +// For format details, see https://containers.dev/implementors/json_reference/ +{ + "name": "core_dev", + "dockerComposeFile": [ + "../local.yml" + ], + "init": true, + "mounts": [ + { + "source": "./.devcontainer/bash_history", + "target": "/home/dev-user/.bash_history", + "type": "bind" + }, + { + "source": "~/.ssh", + "target": "/home/dev-user/.ssh", + "type": "bind" + } + ], + // Tells devcontainer.json supporting services / tools whether they should run + // /bin/sh -c "while sleep 1000; do :; done" when starting the container instead of the container’s default command + "overrideCommand": false, + "service": "django", + // "remoteEnv": {"PATH": "/home/dev-user/.local/bin:${containerEnv:PATH}"}, + "remoteUser": "dev-user", + "workspaceFolder": "/app", + // Set *default* container specific settings.json values on container create. + "customizations": { + }, + // Uncomment the next line if you want start specific services in your Docker Compose config. + // "runServices": [], + // Uncomment the next line if you want to keep your containers running after VS Code shuts down. + // "shutdownAction": "none", + // Uncomment the next line to run commands after the container is created. + "postCreateCommand": "cat .devcontainer/bashrc.override.sh >> ~/.bashrc" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e71f10ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.editorconfig +.gitattributes +.github +.gitignore +.gitlab-ci.yml +.idea +.pre-commit-config.yaml +.readthedocs.yml +.travis.yml +venv +.git +.envs/ + +scripts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..c0ce3426 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + +[*.{html,css,scss,json,yml,xml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[default.conf] +indent_style = space +indent_size = 2 diff --git a/.envs/.local/.django b/.envs/.local/.django new file mode 100644 index 00000000..9fc47cd9 --- /dev/null +++ b/.envs/.local/.django @@ -0,0 +1,14 @@ +# General +# ------------------------------------------------------------------------------ +USE_DOCKER=yes +IPYTHONDIR=/app/.ipython +# Redis +# ------------------------------------------------------------------------------ +REDIS_URL=redis://redis:6379/0 + +# Celery +# ------------------------------------------------------------------------------ + +# Flower +CELERY_FLOWER_USER=OQEWmzIWBMsETbtluAgjioZoZJABbxBW +CELERY_FLOWER_PASSWORD=z8XDI1lrBqeOV4BtEj1zzm7FcMSBsvZD1gIeK55C7xQm68mBx6EevZ1b65zUXgRq diff --git a/.envs/.local/.postgres b/.envs/.local/.postgres new file mode 100644 index 00000000..7f17a83d --- /dev/null +++ b/.envs/.local/.postgres @@ -0,0 +1,7 @@ +# PostgreSQL +# ------------------------------------------------------------------------------ +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=core +POSTGRES_USER=HnxrraTbiJRxonMIUdYoYRTMnZhVxvdK +POSTGRES_PASSWORD=sDD5eArPjKohhdqiGol1edlg2gQcUFnUesPaJYTU1okrx92wJyy0dB9D518B0uL8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..176a458f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fd53d963 --- /dev/null +++ b/.gitignore @@ -0,0 +1,335 @@ +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +staticfiles/ + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# Environments +.venv +venv/ +ENV/ + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for devcontainer +.devcontainer/bash_history + +# Provided default Pycharm Run/Debug Configurations should be tracked by git +# In case of local modifications made by Pycharm, use update-index command +# for each changed file, like this: +# git update-index --assume-unchanged .idea/core.iml +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + + +### macOS template +# General +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### SublimeText template +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + + +### Vim template +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist + +# Auto-generated tag files +tags + +# Redis dump file +dump.rdb + +### Project template +core/media/ + +.pytest_cache/ +.ipython/ +.env +.envs/* +!.envs/.local/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/core.iml b/.idea/core.iml new file mode 100644 index 00000000..b088fbab --- /dev/null +++ b/.idea/core.iml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..49ee1be4 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,28 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..a4d068c4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..3d5284e4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.idea/runConfigurations/docker_compose_up_django.xml b/.idea/runConfigurations/docker_compose_up_django.xml new file mode 100644 index 00000000..2e3e1776 --- /dev/null +++ b/.idea/runConfigurations/docker_compose_up_django.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/.idea/runConfigurations/docker_compose_up_docs.xml b/.idea/runConfigurations/docker_compose_up_docs.xml new file mode 100644 index 00000000..0f77b28d --- /dev/null +++ b/.idea/runConfigurations/docker_compose_up_docs.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/.idea/runConfigurations/merge_production_dotenvs_in_dotenv.xml b/.idea/runConfigurations/merge_production_dotenvs_in_dotenv.xml new file mode 100644 index 00000000..40df2792 --- /dev/null +++ b/.idea/runConfigurations/merge_production_dotenvs_in_dotenv.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/.idea/runConfigurations/migrate.xml b/.idea/runConfigurations/migrate.xml new file mode 100644 index 00000000..cddb44b8 --- /dev/null +++ b/.idea/runConfigurations/migrate.xml @@ -0,0 +1,32 @@ + + + + + diff --git a/.idea/runConfigurations/pytest___.xml b/.idea/runConfigurations/pytest___.xml new file mode 100644 index 00000000..f385ccc5 --- /dev/null +++ b/.idea/runConfigurations/pytest___.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/pytest__users.xml b/.idea/runConfigurations/pytest__users.xml new file mode 100644 index 00000000..bc692810 --- /dev/null +++ b/.idea/runConfigurations/pytest__users.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/runserver.xml b/.idea/runConfigurations/runserver.xml new file mode 100644 index 00000000..8611e776 --- /dev/null +++ b/.idea/runConfigurations/runserver.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/.idea/runConfigurations/runserver_plus.xml b/.idea/runConfigurations/runserver_plus.xml new file mode 100644 index 00000000..304975f4 --- /dev/null +++ b/.idea/runConfigurations/runserver_plus.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..5ace414d --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/.idea/webResources.xml b/.idea/webResources.xml new file mode 100644 index 00000000..3e7ffac5 --- /dev/null +++ b/.idea/webResources.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..9d033cc2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,60 @@ +exclude: '^docs/|/migrations/|devcontainer.json' +default_stages: [commit] + +default_language_version: + python: python3.11 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-json + - id: check-toml + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: detect-private-key + + - repo: https://github.com/adamchainz/django-upgrade + rev: '1.15.0' + hooks: + - id: django-upgrade + args: ['--target-version', '4.2'] + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py311-plus] + + - repo: https://github.com/psf/black + rev: 23.11.0 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + + - repo: https://github.com/Riverside-Healthcare/djLint + rev: v1.34.0 + hooks: + - id: djlint-reformat-django + - id: djlint-django + +# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..d5a8ef66 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,20 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: '3.11' + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Python requirements required to build your docs +python: + install: + - requirements: requirements/local.txt diff --git a/README.md b/README.md index e54ba128..5eee585d 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,14 @@ Write an API for validating a set of tasks (validation means if the set of tasks - description: desc 1 - owner: nilva.man - time to send: 2020-05-10 10:30 - - pre-tasks: + - pre-tasks: - Task - id: 2 - title: task 2 - description: desc 2 - owner: nilva.man - time to send: 2020-05-06 10:30 - - pre-tasks: + - pre-tasks: - 1 - 3 - Task @@ -49,7 +49,7 @@ Write an API for validating a set of tasks (validation means if the set of tasks - description: desc 3 - owner: nilva.man - time to send: 2020-02-10 9:30 - - pre-tasks: + - pre-tasks: result: **No**, task 1 happens after task 2, but is a precondition of task 2, which makes it impossible to happen @@ -60,14 +60,14 @@ result: **No**, task 1 happens after task 2, but is a precondition of task 2, wh - description: desc 1 - owner: nilva.man - time to send: 2020-05-10 10:30 - - pre-tasks: + - pre-tasks: - Task - id: 2 - title: task 2 - description: desc 2 - owner: nilva.man - time to send: 2020-06-10 12:30 - - pre-tasks: + - pre-tasks: - 1 - 3 - Task @@ -76,7 +76,7 @@ result: **No**, task 1 happens after task 2, but is a precondition of task 2, wh - description: desc 3 - owner: nilva.man - time to send: 2020-06-01 12:30 - - pre-tasks: + - pre-tasks: - 1 result: **Yes**, First task 1 will happen, then task 3, then task 2 @@ -101,3 +101,10 @@ So What does matter to us? 7. Enjoy **Finally** don't be afraid to ask anything from us. + + +## Descriptions +Hello +I prefer to send additional details by email. + +Thanks for your trust. diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile new file mode 100644 index 00000000..4b8946f2 --- /dev/null +++ b/compose/local/django/Dockerfile @@ -0,0 +1,91 @@ +# define an alias for the specific python version used in this file. +FROM python:3.11.7-slim-bullseye as python + +# Python build stage +FROM python as python-build-stage + +ARG BUILD_ENVIRONMENT=local + +# Install apt packages +RUN apt-get update && apt-get install --no-install-recommends -y \ + # dependencies for building Python packages + build-essential \ + # psycopg2 dependencies + libpq-dev + +# Requirements are installed here to ensure they will be cached. +COPY ./requirements . + +# Create Python Dependency and Sub-Dependency Wheels. +RUN pip wheel --wheel-dir /usr/src/app/wheels \ + -r ${BUILD_ENVIRONMENT}.txt + + +# Python 'run' stage +FROM python as python-run-stage + +ARG BUILD_ENVIRONMENT=local +ARG APP_HOME=/app + +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV BUILD_ENV ${BUILD_ENVIRONMENT} + +WORKDIR ${APP_HOME} + + +# devcontainer dependencies and utils +RUN apt-get update && apt-get install --no-install-recommends -y \ + sudo git bash-completion nano ssh + +# Create devcontainer user and add it to sudoers +RUN groupadd --gid 1000 dev-user \ + && useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \ + && echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \ + && chmod 0440 /etc/sudoers.d/dev-user + + +# Install required system dependencies +RUN apt-get update && apt-get install --no-install-recommends -y \ + # psycopg2 dependencies + libpq-dev \ + # Translations dependencies + gettext \ + # cleaning up unused files + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && rm -rf /var/lib/apt/lists/* + +# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction +# copy python dependency wheels from python-build-stage +COPY --from=python-build-stage /usr/src/app/wheels /wheels/ + +# use wheels to install python dependencies +RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ + && rm -rf /wheels/ + +COPY ./compose/production/django/entrypoint /entrypoint +RUN sed -i 's/\r$//g' /entrypoint +RUN chmod +x /entrypoint + +COPY ./compose/local/django/start /start +RUN sed -i 's/\r$//g' /start +RUN chmod +x /start + + +COPY ./compose/local/django/celery/worker/start /start-celeryworker +RUN sed -i 's/\r$//g' /start-celeryworker +RUN chmod +x /start-celeryworker + +COPY ./compose/local/django/celery/beat/start /start-celerybeat +RUN sed -i 's/\r$//g' /start-celerybeat +RUN chmod +x /start-celerybeat + +COPY ./compose/local/django/celery/flower/start /start-flower +RUN sed -i 's/\r$//g' /start-flower +RUN chmod +x /start-flower + + +# copy application code to WORKDIR +COPY . ${APP_HOME} + +ENTRYPOINT ["/entrypoint"] diff --git a/compose/local/django/celery/beat/start b/compose/local/django/celery/beat/start new file mode 100644 index 00000000..8adc4891 --- /dev/null +++ b/compose/local/django/celery/beat/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o nounset + + +rm -f './celerybeat.pid' +exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app beat -l INFO' diff --git a/compose/local/django/celery/flower/start b/compose/local/django/celery/flower/start new file mode 100644 index 00000000..b4783d2f --- /dev/null +++ b/compose/local/django/celery/flower/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o nounset + +exec watchfiles --filter python celery.__main__.main \ + --args \ + "-A config.celery_app -b \"${CELERY_BROKER_URL}\" flower --basic_auth=\"${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}\"" diff --git a/compose/local/django/celery/worker/start b/compose/local/django/celery/worker/start new file mode 100644 index 00000000..183a8015 --- /dev/null +++ b/compose/local/django/celery/worker/start @@ -0,0 +1,7 @@ +#!/bin/bash + +set -o errexit +set -o nounset + + +exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO' diff --git a/compose/local/django/start b/compose/local/django/start new file mode 100644 index 00000000..ba96db43 --- /dev/null +++ b/compose/local/django/start @@ -0,0 +1,9 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + +python manage.py migrate +exec python manage.py runserver_plus 0.0.0.0:8000 diff --git a/compose/local/docs/Dockerfile b/compose/local/docs/Dockerfile new file mode 100644 index 00000000..37e3baa3 --- /dev/null +++ b/compose/local/docs/Dockerfile @@ -0,0 +1,62 @@ +# define an alias for the specific python version used in this file. +FROM python:3.11.7-slim-bullseye as python + + +# Python build stage +FROM python as python-build-stage + +ENV PYTHONDONTWRITEBYTECODE 1 + +RUN apt-get update && apt-get install --no-install-recommends -y \ + # dependencies for building Python packages + build-essential \ + # psycopg2 dependencies + libpq-dev \ + # cleaning up unused files + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && rm -rf /var/lib/apt/lists/* + +# Requirements are installed here to ensure they will be cached. +COPY ./requirements /requirements + +# create python dependency wheels +RUN pip wheel --no-cache-dir --wheel-dir /usr/src/app/wheels \ + -r /requirements/local.txt -r /requirements/production.txt \ + && rm -rf /requirements + + +# Python 'run' stage +FROM python as python-run-stage + +ARG BUILD_ENVIRONMENT +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 + +RUN apt-get update && apt-get install --no-install-recommends -y \ + # To run the Makefile + make \ + # psycopg2 dependencies + libpq-dev \ + # Translations dependencies + gettext \ + # Uncomment below lines to enable Sphinx output to latex and pdf + # texlive-latex-recommended \ + # texlive-fonts-recommended \ + # texlive-latex-extra \ + # latexmk \ + # cleaning up unused files + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && rm -rf /var/lib/apt/lists/* + +# copy python dependency wheels from python-build-stage +COPY --from=python-build-stage /usr/src/app/wheels /wheels + +# use wheels to install python dependencies +RUN pip install --no-cache /wheels/* \ + && rm -rf /wheels + +COPY ./compose/local/docs/start /start-docs +RUN sed -i 's/\r$//g' /start-docs +RUN chmod +x /start-docs + +WORKDIR /docs diff --git a/compose/local/docs/start b/compose/local/docs/start new file mode 100644 index 00000000..96a94f56 --- /dev/null +++ b/compose/local/docs/start @@ -0,0 +1,7 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +exec make livehtml diff --git a/compose/production/aws/Dockerfile b/compose/production/aws/Dockerfile new file mode 100644 index 00000000..4d1ecbb2 --- /dev/null +++ b/compose/production/aws/Dockerfile @@ -0,0 +1,9 @@ +FROM garland/aws-cli-docker:1.16.140 + +COPY ./compose/production/aws/maintenance /usr/local/bin/maintenance +COPY ./compose/production/postgres/maintenance/_sourced /usr/local/bin/maintenance/_sourced + +RUN chmod +x /usr/local/bin/maintenance/* + +RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ + && rmdir /usr/local/bin/maintenance diff --git a/compose/production/aws/maintenance/download b/compose/production/aws/maintenance/download new file mode 100644 index 00000000..9561d917 --- /dev/null +++ b/compose/production/aws/maintenance/download @@ -0,0 +1,23 @@ +#!/bin/sh + +### Download a file from your Amazon S3 bucket to the postgres /backups folder +### +### Usage: +### $ docker compose -f production.yml run --rm awscli <1> + +set -o errexit +set -o pipefail +set -o nounset + +working_dir="$(dirname ${0})" +source "${working_dir}/_sourced/constants.sh" +source "${working_dir}/_sourced/messages.sh" + +export AWS_ACCESS_KEY_ID="${DJANGO_AWS_ACCESS_KEY_ID}" +export AWS_SECRET_ACCESS_KEY="${DJANGO_AWS_SECRET_ACCESS_KEY}" +export AWS_STORAGE_BUCKET_NAME="${DJANGO_AWS_STORAGE_BUCKET_NAME}" + + +aws s3 cp s3://${AWS_STORAGE_BUCKET_NAME}${BACKUP_DIR_PATH}/${1} ${BACKUP_DIR_PATH}/${1} + +message_success "Finished downloading ${1}." diff --git a/compose/production/aws/maintenance/upload b/compose/production/aws/maintenance/upload new file mode 100644 index 00000000..73c1b9be --- /dev/null +++ b/compose/production/aws/maintenance/upload @@ -0,0 +1,29 @@ +#!/bin/sh + +### Upload the /backups folder to Amazon S3 +### +### Usage: +### $ docker compose -f production.yml run --rm awscli upload + +set -o errexit +set -o pipefail +set -o nounset + +working_dir="$(dirname ${0})" +source "${working_dir}/_sourced/constants.sh" +source "${working_dir}/_sourced/messages.sh" + +export AWS_ACCESS_KEY_ID="${DJANGO_AWS_ACCESS_KEY_ID}" +export AWS_SECRET_ACCESS_KEY="${DJANGO_AWS_SECRET_ACCESS_KEY}" +export AWS_STORAGE_BUCKET_NAME="${DJANGO_AWS_STORAGE_BUCKET_NAME}" + + +message_info "Upload the backups directory to S3 bucket {$AWS_STORAGE_BUCKET_NAME}" + +aws s3 cp ${BACKUP_DIR_PATH} s3://${AWS_STORAGE_BUCKET_NAME}${BACKUP_DIR_PATH} --recursive + +message_info "Cleaning the directory ${BACKUP_DIR_PATH}" + +rm -rf ${BACKUP_DIR_PATH}/* + +message_success "Finished uploading and cleaning." diff --git a/compose/production/django/Dockerfile b/compose/production/django/Dockerfile new file mode 100644 index 00000000..76549c81 --- /dev/null +++ b/compose/production/django/Dockerfile @@ -0,0 +1,96 @@ + +# define an alias for the specific python version used in this file. +FROM python:3.11.7-slim-bullseye as python + +# Python build stage +FROM python as python-build-stage + +ARG BUILD_ENVIRONMENT=production + +# Install apt packages +RUN apt-get update && apt-get install --no-install-recommends -y \ + # dependencies for building Python packages + build-essential \ + # psycopg2 dependencies + libpq-dev + +# Requirements are installed here to ensure they will be cached. +COPY ./requirements . + +# Create Python Dependency and Sub-Dependency Wheels. +RUN pip wheel --wheel-dir /usr/src/app/wheels \ + -r ${BUILD_ENVIRONMENT}.txt + + +# Python 'run' stage +FROM python as python-run-stage + +ARG BUILD_ENVIRONMENT=production +ARG APP_HOME=/app + +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV BUILD_ENV ${BUILD_ENVIRONMENT} + +WORKDIR ${APP_HOME} + +RUN addgroup --system django \ + && adduser --system --ingroup django django + + +# Install required system dependencies +RUN apt-get update && apt-get install --no-install-recommends -y \ + # psycopg2 dependencies + libpq-dev \ + # Translations dependencies + gettext \ + # cleaning up unused files + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && rm -rf /var/lib/apt/lists/* + +# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction +# copy python dependency wheels from python-build-stage +COPY --from=python-build-stage /usr/src/app/wheels /wheels/ + +# use wheels to install python dependencies +RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ + && rm -rf /wheels/ + + +COPY --chown=django:django ./compose/production/django/entrypoint /entrypoint +RUN sed -i 's/\r$//g' /entrypoint +RUN chmod +x /entrypoint + + +COPY --chown=django:django ./compose/production/django/start /start +RUN sed -i 's/\r$//g' /start +RUN chmod +x /start +COPY --chown=django:django ./compose/production/django/celery/worker/start /start-celeryworker +RUN sed -i 's/\r$//g' /start-celeryworker +RUN chmod +x /start-celeryworker + + +COPY --chown=django:django ./compose/production/django/celery/beat/start /start-celerybeat +RUN sed -i 's/\r$//g' /start-celerybeat +RUN chmod +x /start-celerybeat + + +COPY --chown=django:django ./compose/production/django/celery/flower/start /start-flower +RUN sed -i 's/\r$//g' /start-flower +RUN chmod +x /start-flower + + +# copy application code to WORKDIR +COPY --chown=django:django . ${APP_HOME} + +# make django owner of the WORKDIR directory as well. +RUN chown django:django ${APP_HOME} + +USER django + +RUN DATABASE_URL="" \ + CELERY_BROKER_URL="" \ + DJANGO_SETTINGS_MODULE="config.settings.test" \ + python manage.py compilemessages + +ENTRYPOINT ["/entrypoint"] diff --git a/compose/production/django/celery/beat/start b/compose/production/django/celery/beat/start new file mode 100644 index 00000000..42ddca91 --- /dev/null +++ b/compose/production/django/celery/beat/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + +exec celery -A config.celery_app beat -l INFO diff --git a/compose/production/django/celery/flower/start b/compose/production/django/celery/flower/start new file mode 100644 index 00000000..4180d677 --- /dev/null +++ b/compose/production/django/celery/flower/start @@ -0,0 +1,11 @@ +#!/bin/bash + +set -o errexit +set -o nounset + + +exec celery \ + -A config.celery_app \ + -b "${CELERY_BROKER_URL}" \ + flower \ + --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" diff --git a/compose/production/django/celery/worker/start b/compose/production/django/celery/worker/start new file mode 100644 index 00000000..af0c8f7b --- /dev/null +++ b/compose/production/django/celery/worker/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + +exec celery -A config.celery_app worker -l INFO diff --git a/compose/production/django/entrypoint b/compose/production/django/entrypoint new file mode 100644 index 00000000..249d8d9f --- /dev/null +++ b/compose/production/django/entrypoint @@ -0,0 +1,49 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + + +# N.B. If only .env files supported variable expansion... +export CELERY_BROKER_URL="${REDIS_URL}" + + +if [ -z "${POSTGRES_USER}" ]; then + base_postgres_image_default_user='postgres' + export POSTGRES_USER="${base_postgres_image_default_user}" +fi +export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" + +python << END +import sys +import time + +import psycopg + +suggest_unrecoverable_after = 30 +start = time.time() + +while True: + try: + psycopg.connect( + dbname="${POSTGRES_DB}", + user="${POSTGRES_USER}", + password="${POSTGRES_PASSWORD}", + host="${POSTGRES_HOST}", + port="${POSTGRES_PORT}", + ) + break + except psycopg.OperationalError as error: + sys.stderr.write("Waiting for PostgreSQL to become available...\n") + + if time.time() - start > suggest_unrecoverable_after: + sys.stderr.write(" This is taking longer than expected. The following exception may be indicative of an unrecoverable error: '{}'\n".format(error)) + + time.sleep(1) +END + +>&2 echo 'PostgreSQL is available' + +exec "$@" diff --git a/compose/production/django/start b/compose/production/django/start new file mode 100644 index 00000000..97216fa1 --- /dev/null +++ b/compose/production/django/start @@ -0,0 +1,10 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + +python /app/manage.py collectstatic --noinput + +exec /usr/local/bin/gunicorn config.wsgi --bind 0.0.0.0:5000 --chdir=/app diff --git a/compose/production/nginx/Dockerfile b/compose/production/nginx/Dockerfile new file mode 100644 index 00000000..911b16f7 --- /dev/null +++ b/compose/production/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.17.8-alpine +COPY ./compose/production/nginx/default.conf /etc/nginx/conf.d/default.conf diff --git a/compose/production/nginx/default.conf b/compose/production/nginx/default.conf new file mode 100644 index 00000000..562dba86 --- /dev/null +++ b/compose/production/nginx/default.conf @@ -0,0 +1,7 @@ +server { + listen 80; + server_name localhost; + location /media/ { + alias /usr/share/nginx/media/; + } +} diff --git a/compose/production/postgres/Dockerfile b/compose/production/postgres/Dockerfile new file mode 100644 index 00000000..75d1ccb8 --- /dev/null +++ b/compose/production/postgres/Dockerfile @@ -0,0 +1,6 @@ +FROM postgres:15 + +COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance +RUN chmod +x /usr/local/bin/maintenance/* +RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ + && rmdir /usr/local/bin/maintenance diff --git a/compose/production/postgres/maintenance/_sourced/constants.sh b/compose/production/postgres/maintenance/_sourced/constants.sh new file mode 100644 index 00000000..6ca4f0ca --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/constants.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + + +BACKUP_DIR_PATH='/backups' +BACKUP_FILE_PREFIX='backup' diff --git a/compose/production/postgres/maintenance/_sourced/countdown.sh b/compose/production/postgres/maintenance/_sourced/countdown.sh new file mode 100644 index 00000000..e6cbfb6f --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/countdown.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + + +countdown() { + declare desc="A simple countdown. Source: https://superuser.com/a/611582" + local seconds="${1}" + local d=$(($(date +%s) + "${seconds}")) + while [ "$d" -ge `date +%s` ]; do + echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r"; + sleep 0.1 + done +} diff --git a/compose/production/postgres/maintenance/_sourced/messages.sh b/compose/production/postgres/maintenance/_sourced/messages.sh new file mode 100644 index 00000000..f6be756e --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/messages.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + + +message_newline() { + echo +} + +message_debug() +{ + echo -e "DEBUG: ${@}" +} + +message_welcome() +{ + echo -e "\e[1m${@}\e[0m" +} + +message_warning() +{ + echo -e "\e[33mWARNING\e[0m: ${@}" +} + +message_error() +{ + echo -e "\e[31mERROR\e[0m: ${@}" +} + +message_info() +{ + echo -e "\e[37mINFO\e[0m: ${@}" +} + +message_suggestion() +{ + echo -e "\e[33mSUGGESTION\e[0m: ${@}" +} + +message_success() +{ + echo -e "\e[32mSUCCESS\e[0m: ${@}" +} diff --git a/compose/production/postgres/maintenance/_sourced/yes_no.sh b/compose/production/postgres/maintenance/_sourced/yes_no.sh new file mode 100644 index 00000000..fd9cae16 --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/yes_no.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + + +yes_no() { + declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message." + local arg1="${1}" + + local response= + read -r -p "${arg1} (y/[n])? " response + if [[ "${response}" =~ ^[Yy]$ ]] + then + exit 0 + else + exit 1 + fi +} diff --git a/compose/production/postgres/maintenance/backup b/compose/production/postgres/maintenance/backup new file mode 100644 index 00000000..f72304c0 --- /dev/null +++ b/compose/production/postgres/maintenance/backup @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + + +### Create a database backup. +### +### Usage: +### $ docker compose -f .yml (exec |run --rm) postgres backup + + +set -o errexit +set -o pipefail +set -o nounset + + +working_dir="$(dirname ${0})" +source "${working_dir}/_sourced/constants.sh" +source "${working_dir}/_sourced/messages.sh" + + +message_welcome "Backing up the '${POSTGRES_DB}' database..." + + +if [[ "${POSTGRES_USER}" == "postgres" ]]; then + message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." + exit 1 +fi + +export PGHOST="${POSTGRES_HOST}" +export PGPORT="${POSTGRES_PORT}" +export PGUSER="${POSTGRES_USER}" +export PGPASSWORD="${POSTGRES_PASSWORD}" +export PGDATABASE="${POSTGRES_DB}" + +backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz" +pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}" + + +message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'." diff --git a/compose/production/postgres/maintenance/backups b/compose/production/postgres/maintenance/backups new file mode 100644 index 00000000..a18937d6 --- /dev/null +++ b/compose/production/postgres/maintenance/backups @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + + +### View backups. +### +### Usage: +### $ docker compose -f .yml (exec |run --rm) postgres backups + + +set -o errexit +set -o pipefail +set -o nounset + + +working_dir="$(dirname ${0})" +source "${working_dir}/_sourced/constants.sh" +source "${working_dir}/_sourced/messages.sh" + + +message_welcome "These are the backups you have got:" + +ls -lht "${BACKUP_DIR_PATH}" diff --git a/compose/production/postgres/maintenance/restore b/compose/production/postgres/maintenance/restore new file mode 100644 index 00000000..c68f17d7 --- /dev/null +++ b/compose/production/postgres/maintenance/restore @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + + +### Restore database from a backup. +### +### Parameters: +### <1> filename of an existing backup. +### +### Usage: +### $ docker compose -f .yml (exec |run --rm) postgres restore <1> + + +set -o errexit +set -o pipefail +set -o nounset + + +working_dir="$(dirname ${0})" +source "${working_dir}/_sourced/constants.sh" +source "${working_dir}/_sourced/messages.sh" + + +if [[ -z ${1+x} ]]; then + message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again." + exit 1 +fi +backup_filename="${BACKUP_DIR_PATH}/${1}" +if [[ ! -f "${backup_filename}" ]]; then + message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again." + exit 1 +fi + +message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..." + +if [[ "${POSTGRES_USER}" == "postgres" ]]; then + message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." + exit 1 +fi + +export PGHOST="${POSTGRES_HOST}" +export PGPORT="${POSTGRES_PORT}" +export PGUSER="${POSTGRES_USER}" +export PGPASSWORD="${POSTGRES_PASSWORD}" +export PGDATABASE="${POSTGRES_DB}" + +message_info "Dropping the database..." +dropdb "${PGDATABASE}" + +message_info "Creating a new database..." +createdb --owner="${POSTGRES_USER}" + +message_info "Applying the backup to the new database..." +gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}" + +message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup." diff --git a/compose/production/postgres/maintenance/rmbackup b/compose/production/postgres/maintenance/rmbackup new file mode 100644 index 00000000..fdfd20e1 --- /dev/null +++ b/compose/production/postgres/maintenance/rmbackup @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +### Remove a database backup. +### +### Parameters: +### <1> filename of a backup to remove. +### +### Usage: +### $ docker-compose -f .yml (exec |run --rm) postgres rmbackup <1> + + +set -o errexit +set -o pipefail +set -o nounset + + +working_dir="$(dirname ${0})" +source "${working_dir}/_sourced/constants.sh" +source "${working_dir}/_sourced/messages.sh" + + +if [[ -z ${1+x} ]]; then + message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again." + exit 1 +fi +backup_filename="${BACKUP_DIR_PATH}/${1}" +if [[ ! -f "${backup_filename}" ]]; then + message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again." + exit 1 +fi + +message_welcome "Removing the '${backup_filename}' backup file..." + +rm -r "${backup_filename}" + +message_success "The '${backup_filename}' database backup has been removed." diff --git a/compose/production/traefik/Dockerfile b/compose/production/traefik/Dockerfile new file mode 100644 index 00000000..b47b3f1e --- /dev/null +++ b/compose/production/traefik/Dockerfile @@ -0,0 +1,5 @@ +FROM traefik:2.10.6 +RUN mkdir -p /etc/traefik/acme \ + && touch /etc/traefik/acme/acme.json \ + && chmod 600 /etc/traefik/acme/acme.json +COPY ./compose/production/traefik/traefik.yml /etc/traefik diff --git a/compose/production/traefik/traefik.yml b/compose/production/traefik/traefik.yml new file mode 100644 index 00000000..1e673072 --- /dev/null +++ b/compose/production/traefik/traefik.yml @@ -0,0 +1,75 @@ +log: + level: INFO + +entryPoints: + web: + # http + address: ':80' + http: + # https://docs.traefik.io/routing/entrypoints/#entrypoint + redirections: + entryPoint: + to: web-secure + + web-secure: + # https + address: ':443' + + flower: + address: ':5555' + +certificatesResolvers: + letsencrypt: + # https://docs.traefik.io/master/https/acme/#lets-encrypt + acme: + email: 'sajjad.eb@gmail.com' + storage: /etc/traefik/acme/acme.json + # https://docs.traefik.io/master/https/acme/#httpchallenge + httpChallenge: + entryPoint: web + +http: + routers: + web-secure-router: + rule: 'Host(`example.com`) || Host(`www.example.com`)' + entryPoints: + - web-secure + middlewares: + - csrf + service: django + tls: + # https://docs.traefik.io/master/routing/routers/#certresolver + certResolver: letsencrypt + + flower-secure-router: + rule: 'Host(`example.com`)' + entryPoints: + - flower + service: flower + tls: + # https://docs.traefik.io/master/routing/routers/#certresolver + certResolver: letsencrypt + + middlewares: + csrf: + # https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders + # https://docs.djangoproject.com/en/dev/ref/csrf/#ajax + headers: + hostsProxyHeaders: ['X-CSRFToken'] + + services: + django: + loadBalancer: + servers: + - url: http://django:5000 + + flower: + loadBalancer: + servers: + - url: http://flower:5555 + +providers: + # https://docs.traefik.io/master/providers/file/ + file: + filename: /etc/traefik/traefik.yml + watch: true diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 00000000..10f50142 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery_app import app as celery_app + +__all__ = ("celery_app",) diff --git a/config/api_router.py b/config/api_router.py new file mode 100644 index 00000000..9a1f0eb1 --- /dev/null +++ b/config/api_router.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.urls import path +from rest_framework.routers import DefaultRouter, SimpleRouter + +from task.views import TaskValidatorAPIView + +urlpatterns = [ + path("validator", TaskValidatorAPIView.as_view(), name="task_validator"), +] + +if settings.DEBUG: + router = DefaultRouter() +else: + router = SimpleRouter() + +# router.register("users", UserViewSet) + +# urlpatterns += router.urls diff --git a/config/celery_app.py b/config/celery_app.py new file mode 100644 index 00000000..f821f5dd --- /dev/null +++ b/config/celery_app.py @@ -0,0 +1,17 @@ +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + +app = Celery("core") + +# 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 app configs. +app.autodiscover_tasks() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 00000000..059945ec --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,334 @@ +""" +Base settings to build other settings files upon. +""" +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent +# core/ +APPS_DIR = BASE_DIR / "core" +env = environ.Env() + +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) +if READ_DOT_ENV_FILE: + # OS environment variables take precedence over variables from .env + env.read_env(str(BASE_DIR / ".env")) + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = env.bool("DJANGO_DEBUG", False) +# Local time zone. Choices are +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# though not all of them may be available with every OS. +# In Windows, this must be set to your system time zone. +TIME_ZONE = "Asia/Tehran" +# https://docs.djangoproject.com/en/dev/ref/settings/#language-code +LANGUAGE_CODE = "en-us" +# https://docs.djangoproject.com/en/dev/ref/settings/#languages +# from django.utils.translation import gettext_lazy as _ +# LANGUAGES = [ +# ('en', _('English')), +# ('fr-fr', _('French')), +# ('pt-br', _('Portuguese')), +# ] +# https://docs.djangoproject.com/en/dev/ref/settings/#site-id +SITE_ID = 1 +# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n +USE_I18N = True +# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz +USE_TZ = True +# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths +LOCALE_PATHS = [str(BASE_DIR / "locale")] + +# DATABASES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#databases +DATABASES = {"default": env.db("DATABASE_URL")} +DATABASES["default"]["ATOMIC_REQUESTS"] = True +# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# URLS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf +ROOT_URLCONF = "config.urls" +# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application +WSGI_APPLICATION = "config.wsgi.application" + +# APPS +# ------------------------------------------------------------------------------ +DJANGO_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + # "django.contrib.humanize", # Handy template tags + "django.contrib.admin", + "django.forms", +] +THIRD_PARTY_APPS = [ + "crispy_forms", + "crispy_bootstrap5", + "allauth", + "allauth.account", + "allauth.socialaccount", + "django_celery_beat", + "rest_framework", + "rest_framework.authtoken", + "corsheaders", + "drf_spectacular", +] + +LOCAL_APPS = ["core.users", "task"] +# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + +# MIGRATIONS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules +MIGRATION_MODULES = {"sites": "core.contrib.sites.migrations"} + +# AUTHENTICATION +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model +AUTH_USER_MODEL = "users.User" +# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url +LOGIN_REDIRECT_URL = "users:redirect" +# https://docs.djangoproject.com/en/dev/ref/settings/#login-url +LOGIN_URL = "account_login" + +# PASSWORDS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers +PASSWORD_HASHERS = [ + # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# MIDDLEWARE +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#middleware +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", +] + +# STATIC +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#static-root +STATIC_ROOT = str(BASE_DIR / "staticfiles") +# https://docs.djangoproject.com/en/dev/ref/settings/#static-url +STATIC_URL = "/static/" +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS +STATICFILES_DIRS = [str(APPS_DIR / "static")] +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +# MEDIA +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#media-root +MEDIA_ROOT = str(APPS_DIR / "media") +# https://docs.djangoproject.com/en/dev/ref/settings/#media-url +MEDIA_URL = "/media/" + +# TEMPLATES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#templates +TEMPLATES = [ + { + # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND + "BACKEND": "django.template.backends.django.DjangoTemplates", + # https://docs.djangoproject.com/en/dev/ref/settings/#dirs + "DIRS": [str(APPS_DIR / "templates")], + # https://docs.djangoproject.com/en/dev/ref/settings/#app-dirs + "APP_DIRS": True, + "OPTIONS": { + # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + "core.users.context_processors.allauth_settings", + ], + }, + } +] + +# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + +# http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs +CRISPY_TEMPLATE_PACK = "bootstrap5" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" + +# FIXTURES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs +FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),) + +# SECURITY +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly +SESSION_COOKIE_HTTPONLY = True +# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly +CSRF_COOKIE_HTTPONLY = True +# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options +X_FRAME_OPTIONS = "DENY" + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +EMAIL_BACKEND = env( + "DJANGO_EMAIL_BACKEND", + default="django.core.mail.backends.smtp.EmailBackend", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout +EMAIL_TIMEOUT = 5 + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL. +ADMIN_URL = "admin/" +# https://docs.djangoproject.com/en/dev/ref/settings/#admins +ADMINS = [("""Webneshin""", "sajjad.eb@gmail.com")] +# https://docs.djangoproject.com/en/dev/ref/settings/#managers +MANAGERS = ADMINS +# https://cookiecutter-django.readthedocs.io/en/latest/settings.html#other-environment-settings +# Force the `admin` sign in process to go through the `django-allauth` workflow +DJANGO_ADMIN_FORCE_ALLAUTH = env.bool("DJANGO_ADMIN_FORCE_ALLAUTH", default=False) + +# LOGGING +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#logging +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + } + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} + +# Celery +# ------------------------------------------------------------------------------ +if USE_TZ: + # https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-timezone + CELERY_TIMEZONE = TIME_ZONE +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-broker_url +CELERY_BROKER_URL = env("CELERY_BROKER_URL") +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-result_backend +CELERY_RESULT_BACKEND = CELERY_BROKER_URL +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-extended +CELERY_RESULT_EXTENDED = True +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-backend-always-retry +# https://github.com/celery/celery/pull/6122 +CELERY_RESULT_BACKEND_ALWAYS_RETRY = True +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-backend-max-retries +CELERY_RESULT_BACKEND_MAX_RETRIES = 10 +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-accept_content +CELERY_ACCEPT_CONTENT = ["json"] +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-task_serializer +CELERY_TASK_SERIALIZER = "json" +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-result_serializer +CELERY_RESULT_SERIALIZER = "json" +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-time-limit +# TODO: set to whatever value is adequate in your circumstances +CELERY_TASK_TIME_LIMIT = 5 * 60 +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-soft-time-limit +# TODO: set to whatever value is adequate in your circumstances +CELERY_TASK_SOFT_TIME_LIMIT = 60 +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events +CELERY_WORKER_SEND_TASK_EVENTS = True +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event +CELERY_TASK_SEND_SENT_EVENT = True +# django-allauth +# ------------------------------------------------------------------------------ +ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True) +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_AUTHENTICATION_METHOD = "username" +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_EMAIL_REQUIRED = True +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_EMAIL_VERIFICATION = "mandatory" +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_ADAPTER = "core.users.adapters.AccountAdapter" +# https://django-allauth.readthedocs.io/en/latest/forms.html +ACCOUNT_FORMS = {"signup": "core.users.forms.UserSignupForm"} +# https://django-allauth.readthedocs.io/en/latest/configuration.html +SOCIALACCOUNT_ADAPTER = "core.users.adapters.SocialAccountAdapter" +# https://django-allauth.readthedocs.io/en/latest/forms.html +SOCIALACCOUNT_FORMS = {"signup": "core.users.forms.UserSocialSignupForm"} + +# django-rest-framework +# ------------------------------------------------------------------------------- +# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticatedOrReadOnly",), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup +CORS_URLS_REGEX = r"^/api/.*$" + +# By Default swagger ui is available only to admin user(s). You can change permission classes to change that +# See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings +SPECTACULAR_SETTINGS = { + "TITLE": "core API", + "DESCRIPTION": "Documentation of API endpoints of core", + "VERSION": "1.0.0", + "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticatedOrReadOnly"], +} +# Webneshin +MAX_SECONDS_TO_DONE_TASK = 60 * 5 diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 00000000..021655a6 --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1,60 @@ +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = True +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env( + "DJANGO_SECRET_KEY", + default="tY0Z7PpbgSzk5EsxulEcGrGBErPspLaSwfy4xQQmqgI1JUkiDq9LMZzDGB7mfali", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] + +# CACHES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#caches +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", + } +} + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +EMAIL_BACKEND = env("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend") + +# django-debug-toolbar +# ------------------------------------------------------------------------------ +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites +INSTALLED_APPS += ["debug_toolbar"] # noqa: F405 +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405 +# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config +DEBUG_TOOLBAR_CONFIG = { + "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], + "SHOW_TEMPLATE_CONTEXT": True, +} +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips +INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] +if env("USE_DOCKER") == "yes": + import socket + + hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) + INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips] + +# django-extensions +# ------------------------------------------------------------------------------ +# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration +INSTALLED_APPS += ["django_extensions"] # noqa: F405 +# Celery +# ------------------------------------------------------------------------------ + +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates +CELERY_TASK_EAGER_PROPAGATES = True +# Your stuff... +# ------------------------------------------------------------------------------ diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 00000000..fa5f1e7f --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,199 @@ +import logging + +import sentry_sdk +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env("DJANGO_SECRET_KEY") +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["example.com"]) + +# DATABASES +# ------------------------------------------------------------------------------ +DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa: F405 + +# CACHES +# ------------------------------------------------------------------------------ +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + # Mimicing memcache behavior. + # https://github.com/jazzband/django-redis#memcached-exceptions-behavior + "IGNORE_EXCEPTIONS": True, + }, + } +} + +# SECURITY +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect +SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True) +# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure +SESSION_COOKIE_SECURE = True +# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure +CSRF_COOKIE_SECURE = True +# https://docs.djangoproject.com/en/dev/topics/security/#ssl-https +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-seconds +# TODO: set this to 60 seconds first and then to 518400 once you prove the former works +SECURE_HSTS_SECONDS = 60 +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains +SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True) +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload +SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True) +# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff +SECURE_CONTENT_TYPE_NOSNIFF = env.bool("DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True) + +# STORAGES +# ------------------------------------------------------------------------------ +# https://django-storages.readthedocs.io/en/latest/#installation +INSTALLED_APPS += ["storages"] # noqa: F405 +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID") +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY") +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME") +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_QUERYSTRING_AUTH = False +# DO NOT change these unless you know what you're doing. +_AWS_EXPIRY = 60 * 60 * 24 * 7 +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_S3_OBJECT_PARAMETERS = { + "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate", +} +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_S3_MAX_MEMORY_SIZE = env.int( + "DJANGO_AWS_S3_MAX_MEMORY_SIZE", + default=100_000_000, # 100MB +) +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None) +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#cloudfront +AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None) +aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" +# STATIC +# ------------------------ +STATICFILES_STORAGE = "core.utils.storages.StaticS3Storage" +COLLECTFAST_STRATEGY = "collectfast.strategies.boto3.Boto3Strategy" +STATIC_URL = f"https://{aws_s3_domain}/static/" +# MEDIA +# ------------------------------------------------------------------------------ +DEFAULT_FILE_STORAGE = "core.utils.storages.MediaS3Storage" +MEDIA_URL = f"https://{aws_s3_domain}/media/" + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email +DEFAULT_FROM_EMAIL = env( + "DJANGO_DEFAULT_FROM_EMAIL", + default="core ", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#server-email +SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) +# https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix +EMAIL_SUBJECT_PREFIX = env( + "DJANGO_EMAIL_SUBJECT_PREFIX", + default="[core] ", +) + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL regex. +ADMIN_URL = env("DJANGO_ADMIN_URL") + +# Anymail +# ------------------------------------------------------------------------------ +# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail +INSTALLED_APPS += ["anymail"] # noqa: F405 +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference +# https://anymail.readthedocs.io/en/stable/esps +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +ANYMAIL = {} + +# Collectfast +# ------------------------------------------------------------------------------ +# https://github.com/antonagestam/collectfast#installation +INSTALLED_APPS = ["collectfast"] + INSTALLED_APPS # noqa: F405 + +# LOGGING +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#logging +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. + +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + } + }, + "root": {"level": "INFO", "handlers": ["console"]}, + "loggers": { + "django.db.backends": { + "level": "ERROR", + "handlers": ["console"], + "propagate": False, + }, + # Errors logged by the SDK itself + "sentry_sdk": {"level": "ERROR", "handlers": ["console"], "propagate": False}, + "django.security.DisallowedHost": { + "level": "ERROR", + "handlers": ["console"], + "propagate": False, + }, + }, +} + +# Sentry +# ------------------------------------------------------------------------------ +SENTRY_DSN = env("SENTRY_DSN") +SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO) + +sentry_logging = LoggingIntegration( + level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs + event_level=logging.ERROR, # Send errors as events +) +integrations = [ + sentry_logging, + DjangoIntegration(), + CeleryIntegration(), + RedisIntegration(), +] +sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=integrations, + environment=env("SENTRY_ENVIRONMENT", default="production"), + traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), +) + +# django-rest-framework +# ------------------------------------------------------------------------------- +# Tools that generate code samples can use SERVERS to point to the correct domain +SPECTACULAR_SETTINGS["SERVERS"] = [ # noqa: F405 + {"url": "https://example.com", "description": "Production server"}, +] +# Your stuff... +# ------------------------------------------------------------------------------ diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 00000000..01021fba --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,37 @@ +""" +With these settings, tests run faster. +""" + +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env( + "DJANGO_SECRET_KEY", + default="Sig4eCXBidQ2qbwGV8rBlRK9HLHhARkeS4ugKnRknfH3YCrZuaMv9doCmSmrLc3v", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner +TEST_RUNNER = "django.test.runner.DiscoverRunner" + +# PASSWORDS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +# DEBUGGING FOR TEMPLATES +# ------------------------------------------------------------------------------ +TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore # noqa: F405 + +# MEDIA +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#media-url +MEDIA_URL = 'http://media.testserver' +# Your stuff... +# ------------------------------------------------------------------------------ diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 00000000..325bf01a --- /dev/null +++ b/config/urls.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +urlpatterns = [ + # path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), + # path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), + # Django Admin, use {% url 'admin:index' %} + path(settings.ADMIN_URL, admin.site.urls), + # User management + # path("users/", include("core.users.urls", namespace="users")), + # path("accounts/", include("allauth.urls")), + # Your stuff: custom urls includes go here +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + +starter = "api/v1/" + +# API URLS +urlpatterns += [ + # API base url + path(starter, include("config.api_router")), + # DRF auth token + # path("auth-token/", obtain_auth_token), + path(starter + "schema/", SpectacularAPIView.as_view(), name="api-schema"), + path( + starter + "docs/", + SpectacularSwaggerView.as_view(url_name="api-schema"), + name="api-docs", + ), +] + +if settings.DEBUG: + # This allows the error pages to be debugged during development, just visit + # these url in browser to see how these error pages look like. + # urlpatterns += [ + # path( + # "400/", + # default_views.bad_request, + # kwargs={"exception": Exception("Bad Request!")}, + # ), + # path( + # "403/", + # default_views.permission_denied, + # kwargs={"exception": Exception("Permission Denied")}, + # ), + # path( + # "404/", + # default_views.page_not_found, + # kwargs={"exception": Exception("Page not Found")}, + # ), + # path("500/", default_views.server_error), + # ] + if "debug_toolbar" in settings.INSTALLED_APPS: + import debug_toolbar + + urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 00000000..874ea6fa --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,38 @@ +""" +WSGI config for core project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os +import sys +from pathlib import Path + +from django.core.wsgi import get_wsgi_application + +# This allows easy placement of apps within the interior +# core directory. +BASE_DIR = Path(__file__).resolve(strict=True).parent.parent +sys.path.append(str(BASE_DIR / "core")) +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +application = get_wsgi_application() +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 00000000..9c9b9534 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1.0" +__version_info__ = tuple(int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".")) diff --git a/core/conftest.py b/core/conftest.py new file mode 100644 index 00000000..34312e08 --- /dev/null +++ b/core/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from core.users.models import User +from core.users.tests.factories import UserFactory + + +@pytest.fixture(autouse=True) +def media_storage(settings, tmpdir): + settings.MEDIA_ROOT = tmpdir.strpath + + +@pytest.fixture +def user(db) -> User: + return UserFactory() diff --git a/core/contrib/__init__.py b/core/contrib/__init__.py new file mode 100644 index 00000000..1c7ecc89 --- /dev/null +++ b/core/contrib/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/core/contrib/sites/__init__.py b/core/contrib/sites/__init__.py new file mode 100644 index 00000000..1c7ecc89 --- /dev/null +++ b/core/contrib/sites/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/core/contrib/sites/migrations/0001_initial.py b/core/contrib/sites/migrations/0001_initial.py new file mode 100644 index 00000000..304cd6d7 --- /dev/null +++ b/core/contrib/sites/migrations/0001_initial.py @@ -0,0 +1,42 @@ +import django.contrib.sites.models +from django.contrib.sites.models import _simple_domain_name_validator +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Site", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "domain", + models.CharField( + max_length=100, + verbose_name="domain name", + validators=[_simple_domain_name_validator], + ), + ), + ("name", models.CharField(max_length=50, verbose_name="display name")), + ], + options={ + "ordering": ("domain",), + "db_table": "django_site", + "verbose_name": "site", + "verbose_name_plural": "sites", + }, + bases=(models.Model,), + managers=[("objects", django.contrib.sites.models.SiteManager())], + ) + ] diff --git a/core/contrib/sites/migrations/0002_alter_domain_unique.py b/core/contrib/sites/migrations/0002_alter_domain_unique.py new file mode 100644 index 00000000..2c8d6dac --- /dev/null +++ b/core/contrib/sites/migrations/0002_alter_domain_unique.py @@ -0,0 +1,20 @@ +import django.contrib.sites.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("sites", "0001_initial")] + + operations = [ + migrations.AlterField( + model_name="site", + name="domain", + field=models.CharField( + max_length=100, + unique=True, + validators=[django.contrib.sites.models._simple_domain_name_validator], + verbose_name="domain name", + ), + ) + ] diff --git a/core/contrib/sites/migrations/0003_set_site_domain_and_name.py b/core/contrib/sites/migrations/0003_set_site_domain_and_name.py new file mode 100644 index 00000000..a88d922e --- /dev/null +++ b/core/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -0,0 +1,63 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" +from django.conf import settings +from django.db import migrations + + +def _update_or_create_site_with_sequence(site_model, connection, domain, name): + """Update or create the site with default ID and keep the DB sequence in sync.""" + site, created = site_model.objects.update_or_create( + id=settings.SITE_ID, + defaults={ + "domain": domain, + "name": name, + }, + ) + if created: + # We provided the ID explicitly when creating the Site entry, therefore the DB + # sequence to auto-generate them wasn't used and is now out of sync. If we + # don't do anything, we'll get a unique constraint violation the next time a + # site is created. + # To avoid this, we need to manually update DB sequence and make sure it's + # greater than the maximum value. + max_id = site_model.objects.order_by('-id').first().id + with connection.cursor() as cursor: + cursor.execute("SELECT last_value from django_site_id_seq") + (current_id,) = cursor.fetchone() + if current_id <= max_id: + cursor.execute( + "alter sequence django_site_id_seq restart with %s", + [max_id + 1], + ) + + +def update_site_forward(apps, schema_editor): + """Set site domain and name.""" + Site = apps.get_model("sites", "Site") + _update_or_create_site_with_sequence( + Site, + schema_editor.connection, + "example.com", + "core", + ) + + +def update_site_backward(apps, schema_editor): + """Revert site domain and name to default.""" + Site = apps.get_model("sites", "Site") + _update_or_create_site_with_sequence( + Site, + schema_editor.connection, + "example.com", + "example.com", + ) + + +class Migration(migrations.Migration): + + dependencies = [("sites", "0002_alter_domain_unique")] + + operations = [migrations.RunPython(update_site_forward, update_site_backward)] diff --git a/core/contrib/sites/migrations/0004_alter_options_ordering_domain.py b/core/contrib/sites/migrations/0004_alter_options_ordering_domain.py new file mode 100644 index 00000000..f7118ca8 --- /dev/null +++ b/core/contrib/sites/migrations/0004_alter_options_ordering_domain.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.7 on 2021-02-04 14:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0003_set_site_domain_and_name"), + ] + + operations = [ + migrations.AlterModelOptions( + name="site", + options={ + "ordering": ["domain"], + "verbose_name": "site", + "verbose_name_plural": "sites", + }, + ), + ] diff --git a/core/contrib/sites/migrations/__init__.py b/core/contrib/sites/migrations/__init__.py new file mode 100644 index 00000000..1c7ecc89 --- /dev/null +++ b/core/contrib/sites/migrations/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/core/models.py b/core/models.py new file mode 100644 index 00000000..74f4e021 --- /dev/null +++ b/core/models.py @@ -0,0 +1,10 @@ +from model_utils.models import TimeStampedModel, UUIDModel + +nb = dict(null=True, blank=True) + + +class BaseModel(UUIDModel, TimeStampedModel): + pass + + class Meta: + abstract = True diff --git a/core/static/css/project.css b/core/static/css/project.css new file mode 100644 index 00000000..f1d543da --- /dev/null +++ b/core/static/css/project.css @@ -0,0 +1,13 @@ +/* These styles are generated from project.scss. */ + +.alert-debug { + color: black; + background-color: white; + border-color: #d6e9c6; +} + +.alert-error { + color: #b94a48; + background-color: #f2dede; + border-color: #eed3d7; +} diff --git a/core/static/fonts/.gitkeep b/core/static/fonts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/core/static/images/favicons/favicon.ico b/core/static/images/favicons/favicon.ico new file mode 100644 index 00000000..e1c1dd1a Binary files /dev/null and b/core/static/images/favicons/favicon.ico differ diff --git a/core/static/js/project.js b/core/static/js/project.js new file mode 100644 index 00000000..d26d23b9 --- /dev/null +++ b/core/static/js/project.js @@ -0,0 +1 @@ +/* Project specific Javascript goes here. */ diff --git a/core/users/__init__.py b/core/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/users/adapters.py b/core/users/adapters.py new file mode 100644 index 00000000..34bc55d0 --- /dev/null +++ b/core/users/adapters.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import typing + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings +from django.http import HttpRequest + +if typing.TYPE_CHECKING: + from allauth.socialaccount.models import SocialLogin + from core.users.models import User + + +class AccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request: HttpRequest) -> bool: + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + +class SocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool: + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + def populate_user(self, request: HttpRequest, sociallogin: SocialLogin, data: dict[str, typing.Any]) -> User: + """ + Populates user information from social provider info. + + See: https://django-allauth.readthedocs.io/en/latest/advanced.html?#creating-and-populating-user-instances + """ + user = super().populate_user(request, sociallogin, data) + if not user.name: + if name := data.get("name"): + user.name = name + elif first_name := data.get("first_name"): + user.name = first_name + if last_name := data.get("last_name"): + user.name += f" {last_name}" + return user diff --git a/core/users/admin.py b/core/users/admin.py new file mode 100644 index 00000000..2c42a001 --- /dev/null +++ b/core/users/admin.py @@ -0,0 +1,57 @@ +from django.conf import settings +from django.contrib import admin +from django.contrib.auth import admin as auth_admin +from django.contrib.auth import decorators, get_user_model +from django.utils.translation import gettext_lazy as _ + +from core.users.forms import UserAdminChangeForm, UserAdminCreationForm + +User = get_user_model() + +if settings.DJANGO_ADMIN_FORCE_ALLAUTH: + # Force the `admin` sign in process to go through the `django-allauth` workflow: + # https://django-allauth.readthedocs.io/en/stable/advanced.html#admin + admin.site.login = decorators.login_required(admin.site.login) # type: ignore[method-assign] + + +@admin.register(User) +class UserAdmin(auth_admin.UserAdmin): + form = UserAdminChangeForm + add_form = UserAdminCreationForm + fieldsets = [ + (None, {"fields": ("username", "password")}), + (_("Personal info"), {"fields": ("last_name", "first_name", "email")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ] + list_display = ["username", "last_name", "first_name", "is_superuser"] + search_fields = [ + "last_name", + "first_name", + ] + + # superusers can access to all users but staff users can not access superusers + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(is_superuser=False) + + def get_fieldsets(self, request, obj=None): + if not request.user.is_superuser: + fieldsets = self.fieldsets + del fieldsets[2] + if not obj: + return self.add_fieldsets + return super().get_fieldsets(request, obj) diff --git a/core/users/api/serializers.py b/core/users/api/serializers.py new file mode 100644 index 00000000..03e62978 --- /dev/null +++ b/core/users/api/serializers.py @@ -0,0 +1,16 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from core.users.models import User as UserType + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer[UserType]): + class Meta: + model = User + fields = ["username", "first_name", "last_name", "url"] + + extra_kwargs = { + "url": {"view_name": "api:user-detail", "lookup_field": "username"}, + } diff --git a/core/users/api/views.py b/core/users/api/views.py new file mode 100644 index 00000000..98bb04e7 --- /dev/null +++ b/core/users/api/views.py @@ -0,0 +1,25 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from .serializers import UserSerializer + +User = get_user_model() + + +class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet): + serializer_class = UserSerializer + queryset = User.objects.all() + lookup_field = "username" + + def get_queryset(self, *args, **kwargs): + assert isinstance(self.request.user.id, int) + return self.queryset.filter(id=self.request.user.id) + + @action(detail=False) + def me(self, request): + serializer = UserSerializer(request.user, context={"request": request}) + return Response(status=status.HTTP_200_OK, data=serializer.data) diff --git a/core/users/apps.py b/core/users/apps.py new file mode 100644 index 00000000..46591459 --- /dev/null +++ b/core/users/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UsersConfig(AppConfig): + name = "core.users" + verbose_name = _("Users") + + def ready(self): + try: + import core.users.signals # noqa: F401 + except ImportError: + pass diff --git a/core/users/context_processors.py b/core/users/context_processors.py new file mode 100644 index 00000000..e2633aec --- /dev/null +++ b/core/users/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def allauth_settings(request): + """Expose some settings from django-allauth in templates.""" + return { + "ACCOUNT_ALLOW_REGISTRATION": settings.ACCOUNT_ALLOW_REGISTRATION, + } diff --git a/core/users/forms.py b/core/users/forms.py new file mode 100644 index 00000000..c0946bf5 --- /dev/null +++ b/core/users/forms.py @@ -0,0 +1,41 @@ +from allauth.account.forms import SignupForm +from allauth.socialaccount.forms import SignupForm as SocialSignupForm +from django.contrib.auth import forms as admin_forms +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ + +User = get_user_model() + + +class UserAdminChangeForm(admin_forms.UserChangeForm): + class Meta(admin_forms.UserChangeForm.Meta): + model = User + + +class UserAdminCreationForm(admin_forms.UserCreationForm): + """ + Form for User Creation in the Admin Area. + To change user signup, see UserSignupForm and UserSocialSignupForm. + """ + + class Meta(admin_forms.UserCreationForm.Meta): + model = User + error_messages = { + "username": {"unique": _("This username has already been taken.")}, + } + + +class UserSignupForm(SignupForm): + """ + Form that will be rendered on a user sign up section/screen. + Default fields will be added automatically. + Check UserSocialSignupForm for accounts created from social. + """ + + +class UserSocialSignupForm(SocialSignupForm): + """ + Renders the form when user has signed up using social accounts. + Default fields will be added automatically. + See UserSignupForm otherwise. + """ diff --git a/core/users/migrations/0001_initial.py b/core/users/migrations/0001_initial.py new file mode 100644 index 00000000..d86130ba --- /dev/null +++ b/core/users/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 4.2.8 on 2023-12-07 14:50 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("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")), + ( + "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")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "id", + model_utils.fields.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("email", models.EmailField(max_length=254, verbose_name="email address")), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/core/users/migrations/__init__.py b/core/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/users/models.py b/core/users/models.py new file mode 100644 index 00000000..ce4261a6 --- /dev/null +++ b/core/users/models.py @@ -0,0 +1,19 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel + + +class User(AbstractUser, BaseModel): + email = models.EmailField(_("email address")) + + +class OwnedModel(models.Model): + owner = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + ) + + class Meta: + abstract = True diff --git a/core/users/tasks.py b/core/users/tasks.py new file mode 100644 index 00000000..c99341c5 --- /dev/null +++ b/core/users/tasks.py @@ -0,0 +1,11 @@ +from django.contrib.auth import get_user_model + +from config import celery_app + +User = get_user_model() + + +@celery_app.task() +def get_users_count(): + """A pointless Celery task to demonstrate usage.""" + return User.objects.count() diff --git a/core/users/tests/__init__.py b/core/users/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/users/tests/factories.py b/core/users/tests/factories.py new file mode 100644 index 00000000..f9b8cf7b --- /dev/null +++ b/core/users/tests/factories.py @@ -0,0 +1,39 @@ +from collections.abc import Sequence +from typing import Any + +from django.contrib.auth import get_user_model +from factory import Faker, post_generation +from factory.django import DjangoModelFactory + + +class UserFactory(DjangoModelFactory): + username = Faker("user_name") + email = Faker("email") + name = Faker("name") + + @post_generation + def password(self, create: bool, extracted: Sequence[Any], **kwargs): + password = ( + extracted + if extracted + else Faker( + "password", + length=42, + special_chars=True, + digits=True, + upper_case=True, + lower_case=True, + ).evaluate(None, None, extra={"locale": None}) + ) + self.set_password(password) + + @classmethod + def _after_postgeneration(cls, instance, create, results=None): + """Save again the instance if creating and at least one hook ran.""" + if create and results and not cls._meta.skip_postgeneration_save: + # Some post-generation hooks ran, and may have modified us. + instance.save() + + class Meta: + model = get_user_model() + django_get_or_create = ["username"] diff --git a/core/users/tests/test_admin.py b/core/users/tests/test_admin.py new file mode 100644 index 00000000..e8e2a3fe --- /dev/null +++ b/core/users/tests/test_admin.py @@ -0,0 +1,37 @@ +from django.urls import reverse + +from core.users.models import User + + +class TestUserAdmin: + def test_changelist(self, admin_client): + url = reverse("admin:users_user_changelist") + response = admin_client.get(url) + assert response.status_code == 200 + + def test_search(self, admin_client): + url = reverse("admin:users_user_changelist") + response = admin_client.get(url, data={"q": "test"}) + assert response.status_code == 200 + + def test_add(self, admin_client): + url = reverse("admin:users_user_add") + response = admin_client.get(url) + assert response.status_code == 200 + + response = admin_client.post( + url, + data={ + "username": "test", + "password1": "My_R@ndom-P@ssw0rd", + "password2": "My_R@ndom-P@ssw0rd", + }, + ) + assert response.status_code == 302 + assert User.objects.filter(username="test").exists() + + def test_view_user(self, admin_client): + user = User.objects.get(username="admin") + url = reverse("admin:users_user_change", kwargs={"object_id": user.pk}) + response = admin_client.get(url) + assert response.status_code == 200 diff --git a/core/users/tests/test_drf_urls.py b/core/users/tests/test_drf_urls.py new file mode 100644 index 00000000..c7de39dc --- /dev/null +++ b/core/users/tests/test_drf_urls.py @@ -0,0 +1,18 @@ +from django.urls import resolve, reverse + +from core.users.models import User + + +def test_user_detail(user: User): + assert reverse("api:user-detail", kwargs={"username": user.username}) == f"/api/users/{user.username}/" + assert resolve(f"/api/users/{user.username}/").view_name == "api:user-detail" + + +def test_user_list(): + assert reverse("api:user-list") == "/api/users/" + assert resolve("/api/users/").view_name == "api:user-list" + + +def test_user_me(): + assert reverse("api:user-me") == "/api/users/me/" + assert resolve("/api/users/me/").view_name == "api:user-me" diff --git a/core/users/tests/test_drf_views.py b/core/users/tests/test_drf_views.py new file mode 100644 index 00000000..f41a6f84 --- /dev/null +++ b/core/users/tests/test_drf_views.py @@ -0,0 +1,35 @@ +import pytest +from rest_framework.test import APIRequestFactory + +from core.users.api.views import UserViewSet +from core.users.models import User + + +class TestUserViewSet: + @pytest.fixture + def api_rf(self) -> APIRequestFactory: + return APIRequestFactory() + + def test_get_queryset(self, user: User, api_rf: APIRequestFactory): + view = UserViewSet() + request = api_rf.get("/fake-url/") + request.user = user + + view.request = request + + assert user in view.get_queryset() + + def test_me(self, user: User, api_rf: APIRequestFactory): + view = UserViewSet() + request = api_rf.get("/fake-url/") + request.user = user + + view.request = request + + response = view.me(request) # type: ignore + + assert response.data == { + "username": user.username, + "url": f"http://testserver/api/users/{user.username}/", + "name": user.name, + } diff --git a/core/users/tests/test_forms.py b/core/users/tests/test_forms.py new file mode 100644 index 00000000..dc4c2db0 --- /dev/null +++ b/core/users/tests/test_forms.py @@ -0,0 +1,36 @@ +""" +Module for all Form Tests. +""" +from django.utils.translation import gettext_lazy as _ + +from core.users.forms import UserAdminCreationForm +from core.users.models import User + + +class TestUserAdminCreationForm: + """ + Test class for all tests related to the UserAdminCreationForm + """ + + def test_username_validation_error_msg(self, user: User): + """ + Tests UserAdminCreation Form's unique validator functions correctly by testing: + 1) A new user with an existing username cannot be added. + 2) Only 1 error is raised by the UserCreation Form + 3) The desired error message is raised + """ + + # The user already exists, + # hence cannot be created. + form = UserAdminCreationForm( + { + "username": user.username, + "password1": user.password, + "password2": user.password, + } + ) + + assert not form.is_valid() + assert len(form.errors) == 1 + assert "username" in form.errors + assert form.errors["username"][0] == _("This username has already been taken.") diff --git a/core/users/tests/test_models.py b/core/users/tests/test_models.py new file mode 100644 index 00000000..1ce666d8 --- /dev/null +++ b/core/users/tests/test_models.py @@ -0,0 +1,5 @@ +from core.users.models import User + + +def test_user_get_absolute_url(user: User): + assert user.get_absolute_url() == f"/users/{user.username}/" diff --git a/core/users/tests/test_swagger.py b/core/users/tests/test_swagger.py new file mode 100644 index 00000000..f97658b5 --- /dev/null +++ b/core/users/tests/test_swagger.py @@ -0,0 +1,21 @@ +import pytest +from django.urls import reverse + + +def test_swagger_accessible_by_admin(admin_client): + url = reverse("api-docs") + response = admin_client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_swagger_ui_not_accessible_by_normal_user(client): + url = reverse("api-docs") + response = client.get(url) + assert response.status_code == 403 + + +def test_api_schema_generated_successfully(admin_client): + url = reverse("api-schema") + response = admin_client.get(url) + assert response.status_code == 200 diff --git a/core/users/tests/test_tasks.py b/core/users/tests/test_tasks.py new file mode 100644 index 00000000..90a5a06e --- /dev/null +++ b/core/users/tests/test_tasks.py @@ -0,0 +1,16 @@ +import pytest +from celery.result import EagerResult + +from core.users.tasks import get_users_count +from core.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_user_count(settings): + """A basic test to execute the get_users_count Celery task.""" + UserFactory.create_batch(3) + settings.CELERY_TASK_ALWAYS_EAGER = True + task_result = get_users_count.delay() + assert isinstance(task_result, EagerResult) + assert task_result.result == 3 diff --git a/core/users/tests/test_urls.py b/core/users/tests/test_urls.py new file mode 100644 index 00000000..02b9b096 --- /dev/null +++ b/core/users/tests/test_urls.py @@ -0,0 +1,18 @@ +from django.urls import resolve, reverse + +from core.users.models import User + + +def test_detail(user: User): + assert reverse("users:detail", kwargs={"username": user.username}) == f"/users/{user.username}/" + assert resolve(f"/users/{user.username}/").view_name == "users:detail" + + +def test_update(): + assert reverse("users:update") == "/users/~update/" + assert resolve("/users/~update/").view_name == "users:update" + + +def test_redirect(): + assert reverse("users:redirect") == "/users/~redirect/" + assert resolve("/users/~redirect/").view_name == "users:redirect" diff --git a/core/users/tests/test_views.py b/core/users/tests/test_views.py new file mode 100644 index 00000000..219d84cb --- /dev/null +++ b/core/users/tests/test_views.py @@ -0,0 +1,100 @@ +import pytest +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from django.http import HttpRequest, HttpResponseRedirect +from django.test import RequestFactory +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from core.users.forms import UserAdminChangeForm +from core.users.models import User +from core.users.tests.factories import UserFactory +from core.users.views import ( + UserRedirectView, + UserUpdateView, + user_detail_view, +) + +pytestmark = pytest.mark.django_db + + +class TestUserUpdateView: + """ + TODO: + extracting view initialization code as class-scoped fixture + would be great if only pytest-django supported non-function-scoped + fixture db access -- this is a work-in-progress for now: + https://github.com/pytest-dev/pytest-django/pull/258 + """ + + def dummy_get_response(self, request: HttpRequest): + return None + + def test_get_success_url(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + assert view.get_success_url() == f"/users/{user.username}/" + + def test_get_object(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + + assert view.get_object() == user + + def test_form_valid(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + + # Add the session/message middleware to the request + SessionMiddleware(self.dummy_get_response).process_request(request) + MessageMiddleware(self.dummy_get_response).process_request(request) + request.user = user + + view.request = request + + # Initialize the form + form = UserAdminChangeForm() + form.cleaned_data = {} + form.instance = user + view.form_valid(form) + + messages_sent = [m.message for m in messages.get_messages(request)] + assert messages_sent == [_("Information successfully updated")] + + +class TestUserRedirectView: + def test_get_redirect_url(self, user: User, rf: RequestFactory): + view = UserRedirectView() + request = rf.get("/fake-url") + request.user = user + + view.request = request + assert view.get_redirect_url() == f"/users/{user.username}/" + + +class TestUserDetailView: + def test_authenticated(self, user: User, rf: RequestFactory): + request = rf.get("/fake-url/") + request.user = UserFactory() + response = user_detail_view(request, username=user.username) + + assert response.status_code == 200 + + def test_not_authenticated(self, user: User, rf: RequestFactory): + request = rf.get("/fake-url/") + request.user = AnonymousUser() + response = user_detail_view(request, username=user.username) + login_url = reverse(settings.LOGIN_URL) + + assert isinstance(response, HttpResponseRedirect) + assert response.status_code == 302 + assert response.url == f"{login_url}?next=/fake-url/" diff --git a/core/users/urls.py b/core/users/urls.py new file mode 100644 index 00000000..1ae5c611 --- /dev/null +++ b/core/users/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from core.users.views import ( + user_detail_view, + user_redirect_view, + user_update_view, +) + +app_name = "users" +urlpatterns = [ + path("~redirect/", view=user_redirect_view, name="redirect"), + path("~update/", view=user_update_view, name="update"), + path("/", view=user_detail_view, name="detail"), +] diff --git a/core/users/views.py b/core/users/views.py new file mode 100644 index 00000000..5b7bc894 --- /dev/null +++ b/core/users/views.py @@ -0,0 +1,43 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView, RedirectView, UpdateView + +User = get_user_model() + + +class UserDetailView(LoginRequiredMixin, DetailView): + model = User + slug_field = "username" + slug_url_kwarg = "username" + + +user_detail_view = UserDetailView.as_view() + + +class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = User + fields = ["name"] + success_message = _("Information successfully updated") + + def get_success_url(self): + assert self.request.user.is_authenticated # for mypy to know that the user is authenticated + return self.request.user.get_absolute_url() + + def get_object(self): + return self.request.user + + +user_update_view = UserUpdateView.as_view() + + +class UserRedirectView(LoginRequiredMixin, RedirectView): + permanent = False + + def get_redirect_url(self): + return reverse("users:detail", kwargs={"username": self.request.user.username}) + + +user_redirect_view = UserRedirectView.as_view() diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/utils/storages.py b/core/utils/storages.py new file mode 100644 index 00000000..d9a321c5 --- /dev/null +++ b/core/utils/storages.py @@ -0,0 +1,11 @@ +from storages.backends.s3 import S3Storage + + +class StaticS3Storage(S3Storage): + location = "static" + default_acl = "public-read" + + +class MediaS3Storage(S3Storage): + location = "media" + file_overwrite = False diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..69577002 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,29 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = ./_build +APP = /app + +.PHONY: help livehtml apidocs Makefile + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . + +# Build, watch and serve docs with live reload +livehtml: + sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html + +# Outputs rst files from django application code +apidocs: + sphinx-apidoc -o $(SOURCEDIR)/api $(APP) + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 00000000..8772c827 --- /dev/null +++ b/docs/__init__.py @@ -0,0 +1 @@ +# Included so that Django's startproject comment runs against the docs directory diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..e83b848a --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,63 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + +import os +import sys +import django + +if os.getenv("READTHEDOCS", default=False) == "True": + sys.path.insert(0, os.path.abspath("..")) + os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True" + os.environ["USE_DOCKER"] = "no" +else: + sys.path.insert(0, os.path.abspath("/app")) +os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db" +os.environ["CELERY_BROKER_URL"] = os.getenv("REDIS_URL", "redis://redis:6379") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") +django.setup() + +# -- Project information ----------------------------------------------------- + +project = "core" +copyright = """2023, Webneshin""" +author = "Webneshin" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +# Add any paths that contain templates here, relative to this directory. +# templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "alabaster" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ["_static"] diff --git a/docs/howto.rst b/docs/howto.rst new file mode 100644 index 00000000..7c12b898 --- /dev/null +++ b/docs/howto.rst @@ -0,0 +1,38 @@ +How To - Project Documentation +====================================================================== + +Get Started +---------------------------------------------------------------------- + +Documentation can be written as rst files in `core/docs`. + + +To build and serve docs, use the commands:: + + docker compose -f local.yml up docs + + + +Changes to files in `docs/_source` will be picked up and reloaded automatically. + +`Sphinx `_ is the tool used to build documentation. + +Docstrings to Documentation +---------------------------------------------------------------------- + +The sphinx extension `apidoc `_ is used to automatically document code using signatures and docstrings. + +Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon `_ extension for details. + +For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`. + +To compile all docstrings automatically into documentation source files, use the command: + :: + + make apidocs + + +This can be done in the docker container: + :: + + docker run --rm docs make apidocs diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..2c68ad3c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,24 @@ +.. core documentation master file, created by + sphinx-quickstart. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to core's documentation! +====================================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + howto + pycharm/configuration + users + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..4f70eede --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,46 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build -c . +) +set SOURCEDIR=_source +set BUILDDIR=_build +set APP=..\core + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.Install sphinx-autobuild for live serving. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:livehtml +sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html +GOTO :EOF + +:apidocs +sphinx-apidoc -o %SOURCEDIR%/api %APP% +GOTO :EOF + +:help +%SPHINXBUILD% -b help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/pycharm/configuration.rst b/docs/pycharm/configuration.rst new file mode 100644 index 00000000..559cc945 --- /dev/null +++ b/docs/pycharm/configuration.rst @@ -0,0 +1,70 @@ +Docker Remote Debugging +======================= + +To connect to python remote interpreter inside docker, you have to make sure first, that Pycharm is aware of your docker. + +Go to *Settings > Build, Execution, Deployment > Docker*. If you are on linux, you can use docker directly using its socket `unix:///var/run/docker.sock`, if you are on Windows or Mac, make sure that you have docker-machine installed, then you can simply *Import credentials from Docker Machine*. + +.. image:: images/1.png + +Configure Remote Python Interpreter +----------------------------------- + +This repository comes with already prepared "Run/Debug Configurations" for docker. + +.. image:: images/2.png + +But as you can see, at the beginning there is something wrong with them. They have red X on django icon, and they cannot be used, without configuring remote python interpreter. To do that, you have to go to *Settings > Build, Execution, Deployment* first. + + +Next, you have to add new remote python interpreter, based on already tested deployment settings. Go to *Settings > Project > Project Interpreter*. Click on the cog icon, and click *Add Remote*. + +.. image:: images/3.png + +Switch to *Docker Compose* and select `local.yml` file from directory of your project, next set *Service name* to `django` + +.. image:: images/4.png + +Having that, click *OK*. Close *Settings* panel, and wait few seconds... + +.. image:: images/7.png + +After few seconds, all *Run/Debug Configurations* should be ready to use. + +.. image:: images/8.png + +**Things you can do with provided configuration**: + +* run and debug python code + +.. image:: images/f1.png + +* run and debug tests + +.. image:: images/f2.png +.. image:: images/f3.png + +* run and debug migrations or different django management commands + +.. image:: images/f4.png + +* and many others.. + +Known issues +------------ + +* Pycharm hangs on "Connecting to Debugger" + +.. image:: images/issue1.png + +This might be fault of your firewall. Take a look on this ticket - https://youtrack.jetbrains.com/issue/PY-18913 + +* Modified files in `.idea` directory + +Most of the files from `.idea/` were added to `.gitignore` with a few exceptions, which were made, to provide "ready to go" configuration. After adding remote interpreter some of these files are altered by PyCharm: + +.. image:: images/issue2.png + +In theory you can remove them from repository, but then, other people will lose a ability to initialize a project from provided configurations as you did. To get rid of this annoying state, you can run command:: + + $ git update-index --assume-unchanged core.iml diff --git a/docs/pycharm/images/1.png b/docs/pycharm/images/1.png new file mode 100644 index 00000000..06908a12 Binary files /dev/null and b/docs/pycharm/images/1.png differ diff --git a/docs/pycharm/images/2.png b/docs/pycharm/images/2.png new file mode 100644 index 00000000..1fb8cf1e Binary files /dev/null and b/docs/pycharm/images/2.png differ diff --git a/docs/pycharm/images/3.png b/docs/pycharm/images/3.png new file mode 100644 index 00000000..32c93351 Binary files /dev/null and b/docs/pycharm/images/3.png differ diff --git a/docs/pycharm/images/4.png b/docs/pycharm/images/4.png new file mode 100644 index 00000000..cf07f9d8 Binary files /dev/null and b/docs/pycharm/images/4.png differ diff --git a/docs/pycharm/images/7.png b/docs/pycharm/images/7.png new file mode 100644 index 00000000..4f8807e3 Binary files /dev/null and b/docs/pycharm/images/7.png differ diff --git a/docs/pycharm/images/8.png b/docs/pycharm/images/8.png new file mode 100644 index 00000000..05946f2c Binary files /dev/null and b/docs/pycharm/images/8.png differ diff --git a/docs/pycharm/images/f1.png b/docs/pycharm/images/f1.png new file mode 100644 index 00000000..2d8c4b6c Binary files /dev/null and b/docs/pycharm/images/f1.png differ diff --git a/docs/pycharm/images/f2.png b/docs/pycharm/images/f2.png new file mode 100644 index 00000000..b123a478 Binary files /dev/null and b/docs/pycharm/images/f2.png differ diff --git a/docs/pycharm/images/f3.png b/docs/pycharm/images/f3.png new file mode 100644 index 00000000..713ab543 Binary files /dev/null and b/docs/pycharm/images/f3.png differ diff --git a/docs/pycharm/images/f4.png b/docs/pycharm/images/f4.png new file mode 100644 index 00000000..11668ecd Binary files /dev/null and b/docs/pycharm/images/f4.png differ diff --git a/docs/pycharm/images/issue1.png b/docs/pycharm/images/issue1.png new file mode 100644 index 00000000..1bb68eee Binary files /dev/null and b/docs/pycharm/images/issue1.png differ diff --git a/docs/pycharm/images/issue2.png b/docs/pycharm/images/issue2.png new file mode 100644 index 00000000..174f6fdf Binary files /dev/null and b/docs/pycharm/images/issue2.png differ diff --git a/docs/users.rst b/docs/users.rst new file mode 100644 index 00000000..21e08aac --- /dev/null +++ b/docs/users.rst @@ -0,0 +1,15 @@ + .. _users: + +Users +====================================================================== + +Starting a new project, it’s highly recommended to set up a custom user model, +even if the default User model is sufficient for you. + +This model behaves identically to the default user model, +but you’ll be able to customize it in the future if the need arises. + +.. automodule:: core.users.models + :members: + :noindex: + diff --git a/local.yml b/local.yml new file mode 100644 index 00000000..6eb89777 --- /dev/null +++ b/local.yml @@ -0,0 +1,86 @@ +version: '3' + +volumes: + core_local_postgres_data: {} + core_local_postgres_data_backups: {} + +services: + django: &django + build: + context: . + dockerfile: ./compose/local/django/Dockerfile + image: core_local_django + container_name: core_local_django + depends_on: + - postgres + - redis + volumes: + - .:/app:z + env_file: + - ./.envs/.local/.django + - ./.envs/.local/.postgres + ports: + - '8000:8000' + command: /start + + postgres: + build: + context: . + dockerfile: ./compose/production/postgres/Dockerfile + image: core_production_postgres + container_name: core_local_postgres + volumes: + - core_local_postgres_data:/var/lib/postgresql/data + - core_local_postgres_data_backups:/backups + env_file: + - ./.envs/.local/.postgres + ports: + - "5445:5432" + + docs: + image: core_local_docs + container_name: core_local_docs + build: + context: . + dockerfile: ./compose/local/docs/Dockerfile + env_file: + - ./.envs/.local/.django + volumes: + - ./docs:/docs:z + - ./config:/app/config:z + - ./core:/app/core:z + ports: + - '9000:9000' + command: /start-docs + + redis: + image: redis:6 + container_name: core_local_redis + + celeryworker: + <<: *django + image: core_local_celeryworker + container_name: core_local_celeryworker + depends_on: + - redis + - postgres + ports: [] + command: /start-celeryworker + + celerybeat: + <<: *django + image: core_local_celerybeat + container_name: core_local_celerybeat + depends_on: + - redis + - postgres + ports: [] + command: /start-celerybeat + + flower: + <<: *django + image: core_local_flower + container_name: core_local_flower + ports: + - '5555:5555' + command: /start-flower diff --git a/locale/README.md b/locale/README.md new file mode 100644 index 00000000..9911fec2 --- /dev/null +++ b/locale/README.md @@ -0,0 +1,32 @@ +# Translations + +Start by configuring the `LANGUAGES` settings in `base.py`, by uncommenting languages you are willing to support. Then, translations strings will be placed in this folder when running: + +```bash +docker compose -f local.yml run --rm django python manage.py makemessages -all --no-location +``` + +This should generate `django.po` (stands for Portable Object) files under each locale `/LC_MESSAGES/django.po`. Each translatable string in the codebase is collected with its `msgid` and need to be translated as `msgstr`, for example: + +```po +msgid "users" +msgstr "utilisateurs" +``` + +Once all translations are done, they need to be compiled into `.mo` files (stands for Machine Object), which are the actual binary files used by the application: + +```bash +docker compose -f local.yml run --rm django python manage.py compilemessages +``` + +Note that the `.po` files are NOT used by the application directly, so if the `.mo` files are out of dates, the content won't appear as translated even if the `.po` files are up-to-date. + +## Production + +The production image runs `compilemessages` automatically at build time, so as long as your translated source files (PO) are up-to-date, you're good to go. + +## Add a new language + +1. Update the [`LANGUAGES` setting](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-LANGUAGES) to your project's base settings. +2. Create the locale folder for the language next to this file, e.g. `fr_FR` for French. Make sure the case is correct. +3. Run `makemessages` (as instructed above) to generate the PO files for the new language. diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po new file mode 100644 index 00000000..56d3222a --- /dev/null +++ b/locale/en_US/LC_MESSAGES/django.po @@ -0,0 +1,12 @@ +# Translations for the core project +# Copyright (C) 2023 Webneshin +# Webneshin , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 0.1.0\n" +"Language: en-US\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/locale/fr_FR/LC_MESSAGES/django.po b/locale/fr_FR/LC_MESSAGES/django.po new file mode 100644 index 00000000..d7b5e4f9 --- /dev/null +++ b/locale/fr_FR/LC_MESSAGES/django.po @@ -0,0 +1,335 @@ +# Translations for the core project +# Copyright (C) 2023 Webneshin +# Webneshin , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 0.1.0\n" +"Language: fr-FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: core/templates/account/account_inactive.html:5 +#: core/templates/account/account_inactive.html:8 +msgid "Account Inactive" +msgstr "Compte inactif" + +#: core/templates/account/account_inactive.html:10 +msgid "This account is inactive." +msgstr "Ce compte est inactif." + +#: core/templates/account/email.html:7 +msgid "Account" +msgstr "Compte" + +#: core/templates/account/email.html:10 +msgid "E-mail Addresses" +msgstr "Adresses e-mail" + +#: core/templates/account/email.html:13 +msgid "The following e-mail addresses are associated with your account:" +msgstr "Les adresses e-mail suivantes sont associées à votre compte :" + +#: core/templates/account/email.html:27 +msgid "Verified" +msgstr "Vérifié" + +#: core/templates/account/email.html:29 +msgid "Unverified" +msgstr "Non vérifié" + +#: core/templates/account/email.html:31 +msgid "Primary" +msgstr "Primaire" + +#: core/templates/account/email.html:37 +msgid "Make Primary" +msgstr "Changer Primaire" + +#: core/templates/account/email.html:38 +msgid "Re-send Verification" +msgstr "Renvoyer vérification" + +#: core/templates/account/email.html:39 +msgid "Remove" +msgstr "Supprimer" + +#: core/templates/account/email.html:46 +msgid "Warning:" +msgstr "Avertissement:" + +#: core/templates/account/email.html:46 +msgid "" +"You currently do not have any e-mail address set up. You should really add " +"an e-mail address so you can receive notifications, reset your password, etc." +msgstr "" +"Vous n'avez actuellement aucune adresse e-mail configurée. Vous devriez ajouter " +"une adresse e-mail pour reçevoir des notifications, réinitialiser votre mot " +"de passe, etc." + +#: core/templates/account/email.html:51 +msgid "Add E-mail Address" +msgstr "Ajouter une adresse e-mail" + +#: core/templates/account/email.html:56 +msgid "Add E-mail" +msgstr "Ajouter e-mail" + +#: core/templates/account/email.html:66 +msgid "Do you really want to remove the selected e-mail address?" +msgstr "Voulez-vous vraiment supprimer l'adresse e-mail sélectionnée ?" + +#: core/templates/account/email_confirm.html:6 +#: core/templates/account/email_confirm.html:10 +msgid "Confirm E-mail Address" +msgstr "Confirmez votre adresse email" + +#: core/templates/account/email_confirm.html:16 +#, python-format +msgid "" +"Please confirm that %(email)s is an e-mail " +"address for user %(user_display)s." +msgstr "" +"Veuillez confirmer que %(email)s est un e-mail " +"adresse de l'utilisateur %(user_display)s." + +#: core/templates/account/email_confirm.html:20 +msgid "Confirm" +msgstr "Confirm" + +#: core/templates/account/email_confirm.html:27 +#, python-format +msgid "" +"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." +msgstr "" +"Ce lien de confirmation par e-mail a expiré ou n'est pas valide. Veuillez" + "émettre une nouvelle demande de confirmation " +"par e-mail." + +#: core/templates/account/login.html:7 +#: core/templates/account/login.html:11 +#: core/templates/account/login.html:56 +#: core/templates/base.html:72 +msgid "Sign In" +msgstr "S'identifier" + +#: core/templates/account/login.html:17 +msgid "Please sign in with one of your existing third party accounts:" +msgstr "Veuillez vous connecter avec l'un de vos comptes tiers existants :" + +#: core/templates/account/login.html:19 +#, python-format +msgid "" +"Or, sign up for a %(site_name)s account and " +"sign in below:" +msgstr "" +"Ou, créez un compte %(site_name)s et " +"connectez-vous ci-dessous :" + +#: core/templates/account/login.html:32 +msgid "or" +msgstr "ou" + +#: core/templates/account/login.html:41 +#, python-format +msgid "" +"If you have not created an account yet, then please sign up first." +msgstr "" +"Si vous n'avez pas encore créé de compte, veuillez d'abord vous inscrire." + +#: core/templates/account/login.html:55 +msgid "Forgot Password?" +msgstr "Mot de passe oublié?" + +#: core/templates/account/logout.html:5 +#: core/templates/account/logout.html:8 +#: core/templates/account/logout.html:17 +#: core/templates/base.html:61 +msgid "Sign Out" +msgstr "Se déconnecter" + +#: core/templates/account/logout.html:10 +msgid "Are you sure you want to sign out?" +msgstr "Êtes-vous certain de vouloir vous déconnecter?" + +#: core/templates/account/password_change.html:6 +#: core/templates/account/password_change.html:9 +#: core/templates/account/password_change.html:14 +#: core/templates/account/password_reset_from_key.html:5 +#: core/templates/account/password_reset_from_key.html:8 +#: core/templates/account/password_reset_from_key_done.html:4 +#: core/templates/account/password_reset_from_key_done.html:7 +msgid "Change Password" +msgstr "Changer le mot de passe" + +#: core/templates/account/password_reset.html:7 +#: core/templates/account/password_reset.html:11 +#: core/templates/account/password_reset_done.html:6 +#: core/templates/account/password_reset_done.html:9 +msgid "Password Reset" +msgstr "Réinitialisation du mot de passe" + +#: core/templates/account/password_reset.html:16 +msgid "" +"Forgotten your password? Enter your e-mail address below, and we'll send you " +"an e-mail allowing you to reset it." +msgstr "" +"Mot de passe oublié? Entrez votre adresse e-mail ci-dessous, et nous vous " +"enverrons un e-mail vous permettant de le réinitialiser." + +#: core/templates/account/password_reset.html:21 +msgid "Reset My Password" +msgstr "Réinitialiser mon mot de passe" + +#: core/templates/account/password_reset.html:24 +msgid "Please contact us if you have any trouble resetting your password." +msgstr "" +"Veuillez nous contacter si vous rencontrez des difficultés pour réinitialiser" +"votre mot de passe." + +#: core/templates/account/password_reset_done.html:15 +msgid "" +"We have sent you an e-mail. Please contact us if you do not receive it " +"within a few minutes." +msgstr "" +"Nous vous avons envoyé un e-mail. Veuillez nous contacter si vous ne le " +"recevez pas d'ici quelques minutes." + +#: core/templates/account/password_reset_from_key.html:8 +msgid "Bad Token" +msgstr "Token Invalide" + +#: core/templates/account/password_reset_from_key.html:12 +#, python-format +msgid "" +"The password reset link was invalid, possibly because it has already been " +"used. Please request a new password reset." +msgstr "" +"Le lien de réinitialisation du mot de passe n'était pas valide, peut-être parce " +"qu'il a déjà été utilisé. Veuillez faire une " +"nouvelle demande de réinitialisation de mot de passe." + +#: core/templates/account/password_reset_from_key.html:18 +msgid "change password" +msgstr "changer le mot de passe" + +#: core/templates/account/password_reset_from_key.html:21 +#: core/templates/account/password_reset_from_key_done.html:8 +msgid "Your password is now changed." +msgstr "Votre mot de passe est maintenant modifié." + +#: core/templates/account/password_set.html:6 +#: core/templates/account/password_set.html:9 +#: core/templates/account/password_set.html:14 +msgid "Set Password" +msgstr "Définir le mot de passe" + +#: core/templates/account/signup.html:6 +msgid "Signup" +msgstr "S'inscrire" + +#: core/templates/account/signup.html:9 +#: core/templates/account/signup.html:19 +#: core/templates/base.html:67 +msgid "Sign Up" +msgstr "S'inscrire" + +#: core/templates/account/signup.html:11 +#, python-format +msgid "" +"Already have an account? Then please sign in." +msgstr "" +"Vous avez déjà un compte? Alors veuillez vous connecter." + +#: core/templates/account/signup_closed.html:5 +#: core/templates/account/signup_closed.html:8 +msgid "Sign Up Closed" +msgstr "Inscriptions closes" + +#: core/templates/account/signup_closed.html:10 +msgid "We are sorry, but the sign up is currently closed." +msgstr "Désolé, mais l'inscription est actuellement fermée." + +#: core/templates/account/verification_sent.html:5 +#: core/templates/account/verification_sent.html:8 +#: core/templates/account/verified_email_required.html:5 +#: core/templates/account/verified_email_required.html:8 +msgid "Verify Your E-mail Address" +msgstr "Vérifiez votre adresse e-mail" + +#: core/templates/account/verification_sent.html:10 +msgid "" +"We have sent an e-mail to you for verification. Follow the link provided to " +"finalize the signup process. Please contact us if you do not receive it " +"within a few minutes." +msgstr "Nous vous avons envoyé un e-mail pour vérification. Suivez le lien fourni " +"pour finalisez le processus d'inscription. Veuillez nous contacter si vous ne le " +"recevez pas d'ici quelques minutes." + +#: core/templates/account/verified_email_required.html:12 +msgid "" +"This part of the site requires us to verify that\n" +"you are who you claim to be. For this purpose, we require that you\n" +"verify ownership of your e-mail address. " +msgstr "" +"Cette partie du site nous oblige à vérifier que\n" +"vous êtes qui vous prétendez être. Nous vous demandons donc de\n" +"vérifier la propriété de votre adresse e-mail." + +#: core/templates/account/verified_email_required.html:16 +msgid "" +"We have sent an e-mail to you for\n" +"verification. Please click on the link inside this e-mail. Please\n" +"contact us if you do not receive it within a few minutes." +msgstr "" +"Nous vous avons envoyé un e-mail pour\n" +"vérification. Veuillez cliquer sur le lien contenu dans cet e-mail. Veuillez nous\n" +"contacter si vous ne le recevez pas d'ici quelques minutes." + +#: core/templates/account/verified_email_required.html:20 +#, python-format +msgid "" +"Note: you can still change your e-" +"mail address." +msgstr "" +"Remarque : vous pouvez toujours changer votre e-" +"adresse e-mail." + +#: core/templates/base.html:57 +msgid "My Profile" +msgstr "Mon Profil" + +#: core/users/admin.py:17 +msgid "Personal info" +msgstr "Personal info" + +#: core/users/admin.py:19 +msgid "Permissions" +msgstr "Permissions" + +#: core/users/admin.py:30 +msgid "Important dates" +msgstr "Dates importantes" + +#: core/users/apps.py:7 +msgid "Users" +msgstr "Utilisateurs" + +#: core/users/forms.py:24 +#: core/users/tests/test_forms.py:36 +msgid "This username has already been taken." +msgstr "Ce nom d'utilisateur est déjà pris." + +#: core/users/models.py:15 +msgid "Name of User" +msgstr "Nom de l'utilisateur" + +#: core/users/views.py:23 +msgid "Information successfully updated" +msgstr "Informations mises à jour avec succès" diff --git a/locale/pt_BR/LC_MESSAGES/django.po b/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 00000000..f482e865 --- /dev/null +++ b/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,315 @@ +# Translations for the core project +# Copyright (C) 2023 Webneshin +# Webneshin , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 0.1.0\n" +"Language: pt-BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: core/templates/account/account_inactive.html:5 +#: core/templates/account/account_inactive.html:8 +msgid "Account Inactive" +msgstr "Conta Inativa" + +#: core/templates/account/account_inactive.html:10 +msgid "This account is inactive." +msgstr "Esta conta está inativa." + +#: core/templates/account/email.html:7 +msgid "Account" +msgstr "Conta" + +#: core/templates/account/email.html:10 +msgid "E-mail Addresses" +msgstr "Endereços de E-mail" + +#: core/templates/account/email.html:13 +msgid "The following e-mail addresses are associated with your account:" +msgstr "Os seguintes endereços de e-mail estão associados à sua conta:" + +#: core/templates/account/email.html:27 +msgid "Verified" +msgstr "Verificado" + +#: core/templates/account/email.html:29 +msgid "Unverified" +msgstr "Não verificado" + +#: core/templates/account/email.html:31 +msgid "Primary" +msgstr "Primário" + +#: core/templates/account/email.html:37 +msgid "Make Primary" +msgstr "Tornar Primário" + +#: core/templates/account/email.html:38 +msgid "Re-send Verification" +msgstr "Reenviar verificação" + +#: core/templates/account/email.html:39 +msgid "Remove" +msgstr "Remover" + +#: core/templates/account/email.html:46 +msgid "Warning:" +msgstr "Aviso:" + +#: core/templates/account/email.html:46 +msgid "" +"You currently do not have any e-mail address set up. You should really add " +"an e-mail address so you can receive notifications, reset your password, etc." +msgstr "" +"No momento, você não tem nenhum endereço de e-mail configurado. Você " +"realmente deve adicionar um endereço de e-mail para receber notificações, " +"redefinir sua senha etc." + +#: core/templates/account/email.html:51 +msgid "Add E-mail Address" +msgstr "Adicionar Endereço de E-mail" + +#: core/templates/account/email.html:56 +msgid "Add E-mail" +msgstr "Adicionar E-mail" + +#: core/templates/account/email.html:66 +msgid "Do you really want to remove the selected e-mail address?" +msgstr "Você realmente deseja remover o endereço de e-mail selecionado?" + +#: core/templates/account/email_confirm.html:6 +#: core/templates/account/email_confirm.html:10 +msgid "Confirm E-mail Address" +msgstr "Confirme o endereço de e-mail" + +#: core/templates/account/email_confirm.html:16 +#, python-format +msgid "" +"Please confirm that %(email)s is an e-mail " +"address for user %(user_display)s." +msgstr "" +"Confirme se %(email)s é um endereço de " +"e-mail do usuário %(user_display)s." + +#: core/templates/account/email_confirm.html:20 +msgid "Confirm" +msgstr "Confirmar" + +#: core/templates/account/email_confirm.html:27 +#, python-format +msgid "" +"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." +msgstr "Este link de confirmação de e-mail expirou ou é inválido. " +"Por favor, emita um novo pedido de confirmação por e-mail." + +#: core/templates/account/login.html:7 +#: core/templates/account/login.html:11 +#: core/templates/account/login.html:56 +#: core/templates/base.html:72 +msgid "Sign In" +msgstr "Entrar" + +#: core/templates/account/login.html:17 +msgid "Please sign in with one of your existing third party accounts:" +msgstr "Faça login com uma de suas contas de terceiros existentes:" + +#: core/templates/account/login.html:19 +#, python-format +msgid "" +"Or, sign up for a %(site_name)s account and " +"sign in below:" +msgstr "Ou, cadastre-se para uma conta em %(site_name)s e entre abaixo:" + +#: core/templates/account/login.html:32 +msgid "or" +msgstr "ou" + +#: core/templates/account/login.html:41 +#, python-format +msgid "" +"If you have not created an account yet, then please sign up first." +msgstr "Se você ainda não criou uma conta, registre-se primeiro." + +#: core/templates/account/login.html:55 +msgid "Forgot Password?" +msgstr "Esqueceu sua senha?" + +#: core/templates/account/logout.html:5 +#: core/templates/account/logout.html:8 +#: core/templates/account/logout.html:17 +#: core/templates/base.html:61 +msgid "Sign Out" +msgstr "Sair" + +#: core/templates/account/logout.html:10 +msgid "Are you sure you want to sign out?" +msgstr "Você tem certeza que deseja sair?" + +#: core/templates/account/password_change.html:6 +#: core/templates/account/password_change.html:9 +#: core/templates/account/password_change.html:14 +#: core/templates/account/password_reset_from_key.html:5 +#: core/templates/account/password_reset_from_key.html:8 +#: core/templates/account/password_reset_from_key_done.html:4 +#: core/templates/account/password_reset_from_key_done.html:7 +msgid "Change Password" +msgstr "Alterar Senha" + +#: core/templates/account/password_reset.html:7 +#: core/templates/account/password_reset.html:11 +#: core/templates/account/password_reset_done.html:6 +#: core/templates/account/password_reset_done.html:9 +msgid "Password Reset" +msgstr "Redefinição de senha" + +#: core/templates/account/password_reset.html:16 +msgid "" +"Forgotten your password? Enter your e-mail address below, and we'll send you " +"an e-mail allowing you to reset it." +msgstr "Esqueceu sua senha? Digite seu endereço de e-mail abaixo e enviaremos um e-mail permitindo que você o redefina." + +#: core/templates/account/password_reset.html:21 +msgid "Reset My Password" +msgstr "Redefinir minha senha" + +#: core/templates/account/password_reset.html:24 +msgid "Please contact us if you have any trouble resetting your password." +msgstr "Entre em contato conosco se tiver algum problema para redefinir sua senha." + +#: core/templates/account/password_reset_done.html:15 +msgid "" +"We have sent you an e-mail. Please contact us if you do not receive it " +"within a few minutes." +msgstr "Enviamos um e-mail para você. Entre em contato conosco se você não recebê-lo dentro de alguns minutos." + +#: core/templates/account/password_reset_from_key.html:8 +msgid "Bad Token" +msgstr "Token Inválido" + +#: core/templates/account/password_reset_from_key.html:12 +#, python-format +msgid "" +"The password reset link was invalid, possibly because it has already been " +"used. Please request a new password reset." +msgstr "O link de redefinição de senha era inválido, possivelmente porque já foi usado. " +"Solicite uma nova redefinição de senha." + +#: core/templates/account/password_reset_from_key.html:18 +msgid "change password" +msgstr "alterar senha" + +#: core/templates/account/password_reset_from_key.html:21 +#: core/templates/account/password_reset_from_key_done.html:8 +msgid "Your password is now changed." +msgstr "Sua senha agora foi alterada." + +#: core/templates/account/password_set.html:6 +#: core/templates/account/password_set.html:9 +#: core/templates/account/password_set.html:14 +msgid "Set Password" +msgstr "Definir Senha" + +#: core/templates/account/signup.html:6 +msgid "Signup" +msgstr "Cadastro" + +#: core/templates/account/signup.html:9 +#: core/templates/account/signup.html:19 +#: core/templates/base.html:67 +msgid "Sign Up" +msgstr "Cadastro" + +#: core/templates/account/signup.html:11 +#, python-format +msgid "" +"Already have an account? Then please sign in." +msgstr "já tem uma conta? Então, por favor, faça login." + +#: core/templates/account/signup_closed.html:5 +#: core/templates/account/signup_closed.html:8 +msgid "Sign Up Closed" +msgstr "Inscrições encerradas" + +#: core/templates/account/signup_closed.html:10 +msgid "We are sorry, but the sign up is currently closed." +msgstr "Lamentamos, mas as inscrições estão encerradas no momento." + +#: core/templates/account/verification_sent.html:5 +#: core/templates/account/verification_sent.html:8 +#: core/templates/account/verified_email_required.html:5 +#: core/templates/account/verified_email_required.html:8 +msgid "Verify Your E-mail Address" +msgstr "Verifique seu endereço de e-mail" + +#: core/templates/account/verification_sent.html:10 +msgid "" +"We have sent an e-mail to you for verification. Follow the link provided to " +"finalize the signup process. Please contact us if you do not receive it " +"within a few minutes." +msgstr "Enviamos um e-mail para você para verificação. Siga o link fornecido para finalizar o processo de inscrição. Entre em contato conosco se você não recebê-lo dentro de alguns minutos." + +#: core/templates/account/verified_email_required.html:12 +msgid "" +"This part of the site requires us to verify that\n" +"you are who you claim to be. For this purpose, we require that you\n" +"verify ownership of your e-mail address. " +msgstr "Esta parte do site exige que verifiquemos se você é quem afirma ser.\n" +"Para esse fim, exigimos que você verifique a propriedade\n" +"do seu endereço de e-mail." + +#: core/templates/account/verified_email_required.html:16 +msgid "" +"We have sent an e-mail to you for\n" +"verification. Please click on the link inside this e-mail. Please\n" +"contact us if you do not receive it within a few minutes." +msgstr "Enviamos um e-mail para você para verificação.\n" +"Por favor, clique no link dentro deste e-mail.\n" +"Entre em contato conosco se você não recebê-lo dentro de alguns minutos." + +#: core/templates/account/verified_email_required.html:20 +#, python-format +msgid "" +"Note: you can still change your e-" +"mail address." +msgstr "Nota: você ainda pode alterar seu endereço de e-mail." + +#: core/templates/base.html:57 +msgid "My Profile" +msgstr "Meu perfil" + +#: core/users/admin.py:17 +msgid "Personal info" +msgstr "Informação pessoal" + +#: core/users/admin.py:19 +msgid "Permissions" +msgstr "Permissões" + +#: core/users/admin.py:30 +msgid "Important dates" +msgstr "Datas importantes" + +#: core/users/apps.py:7 +msgid "Users" +msgstr "Usuários" + +#: core/users/forms.py:24 +#: core/users/tests/test_forms.py:36 +msgid "This username has already been taken." +msgstr "Este nome de usuário já foi usado." + +#: core/users/models.py:15 +msgid "Name of User" +msgstr "Nome do Usuário" + +#: core/users/views.py:23 +msgid "Information successfully updated" +msgstr "Informação atualizada com sucesso" diff --git a/manage.py b/manage.py new file mode 100644 index 00000000..3239f3fd --- /dev/null +++ b/manage.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django # noqa + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + + raise + + # This allows easy placement of apps within the interior + # core directory. + current_path = Path(__file__).parent.resolve() + sys.path.append(str(current_path / "core")) + + execute_from_command_line(sys.argv) diff --git a/merge_production_dotenvs_in_dotenv.py b/merge_production_dotenvs_in_dotenv.py new file mode 100644 index 00000000..35139fb2 --- /dev/null +++ b/merge_production_dotenvs_in_dotenv.py @@ -0,0 +1,26 @@ +import os +from collections.abc import Sequence +from pathlib import Path + +BASE_DIR = Path(__file__).parent.resolve() +PRODUCTION_DOTENVS_DIR = BASE_DIR / ".envs" / ".production" +PRODUCTION_DOTENV_FILES = [ + PRODUCTION_DOTENVS_DIR / ".django", + PRODUCTION_DOTENVS_DIR / ".postgres", +] +DOTENV_FILE = BASE_DIR / ".env" + + +def merge( + output_file: Path, + files_to_merge: Sequence[Path], +) -> None: + merged_content = "" + for merge_file in files_to_merge: + merged_content += merge_file.read_text() + merged_content += os.linesep + output_file.write_text(merged_content) + + +if __name__ == "__main__": + merge(DOTENV_FILE, PRODUCTION_DOTENV_FILES) diff --git a/production.yml b/production.yml new file mode 100644 index 00000000..50be0b64 --- /dev/null +++ b/production.yml @@ -0,0 +1,73 @@ +version: '3' + +volumes: + production_postgres_data: {} + production_postgres_data_backups: {} + production_traefik: {} + +services: + django: &django + build: + context: . + dockerfile: ./compose/production/django/Dockerfile + + image: core_production_django + depends_on: + - postgres + - redis + env_file: + - ./.envs/.production/.django + - ./.envs/.production/.postgres + command: /start + + postgres: + build: + context: . + dockerfile: ./compose/production/postgres/Dockerfile + image: core_production_postgres + volumes: + - production_postgres_data:/var/lib/postgresql/data + - production_postgres_data_backups:/backups + env_file: + - ./.envs/.production/.postgres + + traefik: + build: + context: . + dockerfile: ./compose/production/traefik/Dockerfile + image: core_production_traefik + depends_on: + - django + volumes: + - production_traefik:/etc/traefik/acme + ports: + - '0.0.0.0:80:80' + - '0.0.0.0:443:443' + - '0.0.0.0:5555:5555' + + redis: + image: redis:6 + + celeryworker: + <<: *django + image: core_production_celeryworker + command: /start-celeryworker + + celerybeat: + <<: *django + image: core_production_celerybeat + command: /start-celerybeat + + flower: + <<: *django + image: core_production_flower + command: /start-flower + + awscli: + build: + context: . + dockerfile: ./compose/production/aws/Dockerfile + env_file: + - ./.envs/.production/.django + volumes: + - production_postgres_data_backups:/backups:z diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..bb7dda13 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,105 @@ +# ==== pytest ==== +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--ds=config.settings.test --reuse-db" +python_files = [ + "tests.py", + "test_*.py", +] + +# ==== Coverage ==== +[tool.coverage.run] +include = ["core/**"] +omit = ["*/migrations/*", "*/tests/*"] +plugins = ["django_coverage_plugin"] + + +# ==== black ==== +[tool.black] +line-length = 119 +target-version = ['py311'] + + +# ==== isort ==== +[tool.isort] +profile = "black" +line_length = 119 +known_first_party = [ + "core", + "config", +] +skip = ["venv/"] +skip_glob = ["**/migrations/*.py"] + + +# ==== mypy ==== +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +plugins = [ + "mypy_django_plugin.main", + "mypy_drf_plugin.main", +] + +[[tool.mypy.overrides]] +# Django migrations should not produce any errors: +module = "*.migrations.*" +ignore_errors = true + +[tool.django-stubs] +django_settings_module = "config.settings.test" + + +# ==== PyLint ==== +[tool.pylint.MASTER] +load-plugins = [ + "pylint_django", + "pylint_celery", +] +django-settings-module = "config.settings.local" + +[tool.pylint.FORMAT] +max-line-length = 119 + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "missing-docstring", + "invalid-name", +] + +[tool.pylint.DESIGN] +max-parents = 13 + +[tool.pylint.TYPECHECK] +generated-members = [ + "REQUEST", + "acl_users", + "aq_parent", + "[a-zA-Z]+_set{1,2}", + "save", + "delete", +] + + +# ==== djLint ==== +[tool.djlint] +blank_line_after_tag = "load,extends" +close_void_tags = true +format_css = true +format_js = true +# TODO: remove T002 when fixed https://github.com/Riverside-Healthcare/djLint/issues/687 +ignore = "H006,H030,H031,T002" +include = "H017,H035" +indent = 2 +max_line_length = 119 +profile = "django" + +[tool.djlint.css] +indent_size = 2 + +[tool.djlint.js] +indent_size = 2 diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 00000000..14f52327 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,23 @@ +python-slugify==8.0.1 # https://github.com/un33k/python-slugify +Pillow==10.1.0 # https://github.com/python-pillow/Pillow +argon2-cffi==23.1.0 # https://github.com/hynek/argon2_cffi +redis==5.0.1 # https://github.com/redis/redis-py +hiredis==2.2.3 # https://github.com/redis/hiredis-py +celery==5.3.6 # pyup: < 6.0 # https://github.com/celery/celery +django-celery-beat==2.5.0 # https://github.com/celery/django-celery-beat +flower==2.0.1 # https://github.com/mher/flower + +# Django +# ------------------------------------------------------------------------------ +django==4.2.8 # pyup: < 5.0 # https://www.djangoproject.com/ +django-environ==0.11.2 # https://github.com/joke2k/django-environ +django-model-utils==4.3.1 # https://github.com/jazzband/django-model-utils +django-allauth==0.58.2 # https://github.com/pennersr/django-allauth +django-crispy-forms==2.1 # https://github.com/django-crispy-forms/django-crispy-forms +crispy-bootstrap5==2023.10 # https://github.com/django-crispy-forms/crispy-bootstrap5 +django-redis==5.4.0 # https://github.com/jazzband/django-redis +# Django REST Framework +djangorestframework==3.14.0 # https://github.com/encode/django-rest-framework +django-cors-headers==4.3.1 # https://github.com/adamchainz/django-cors-headers +# DRF-spectacular for api documentation +drf-spectacular==0.26.5 # https://github.com/tfranzel/drf-spectacular diff --git a/requirements/local.txt b/requirements/local.txt new file mode 100644 index 00000000..b32b6b1b --- /dev/null +++ b/requirements/local.txt @@ -0,0 +1,39 @@ +-r base.txt + +Werkzeug[watchdog]==3.0.1 # https://github.com/pallets/werkzeug +ipdb==0.13.13 # https://github.com/gotcha/ipdb +psycopg[c]==3.1.14 # https://github.com/psycopg/psycopg +watchfiles==0.21.0 # https://github.com/samuelcolvin/watchfiles + +# Testing +# ------------------------------------------------------------------------------ +mypy==1.7.1 # https://github.com/python/mypy +django-stubs[compatible-mypy]==4.2.7 # https://github.com/typeddjango/django-stubs +pytest==7.4.3 # https://github.com/pytest-dev/pytest +pytest-sugar==0.9.7 # https://github.com/Frozenball/pytest-sugar +djangorestframework-stubs[compatible-mypy]==3.14.5 # https://github.com/typeddjango/djangorestframework-stubs + +# Documentation +# ------------------------------------------------------------------------------ +sphinx==7.2.6 # https://github.com/sphinx-doc/sphinx +sphinx-autobuild==2021.3.14 # https://github.com/GaretJax/sphinx-autobuild + +# Code quality +# ------------------------------------------------------------------------------ +flake8==6.1.0 # https://github.com/PyCQA/flake8 +flake8-isort==6.1.1 # https://github.com/gforcada/flake8-isort +coverage==7.3.2 # https://github.com/nedbat/coveragepy +black==23.11.0 # https://github.com/psf/black +djlint==1.34.0 # https://github.com/Riverside-Healthcare/djLint +pylint-django==2.5.5 # https://github.com/PyCQA/pylint-django +pylint-celery==0.3 # https://github.com/PyCQA/pylint-celery +pre-commit==3.5.0 # https://github.com/pre-commit/pre-commit + +# Django +# ------------------------------------------------------------------------------ +factory-boy==3.3.0 # https://github.com/FactoryBoy/factory_boy + +django-debug-toolbar==4.2.0 # https://github.com/jazzband/django-debug-toolbar +django-extensions==3.2.3 # https://github.com/django-extensions/django-extensions +django-coverage-plugin==3.1.0 # https://github.com/nedbat/django_coverage_plugin +pytest-django==4.7.0 # https://github.com/pytest-dev/pytest-django diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 00000000..348faa47 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,13 @@ +# PRECAUTION: avoid production dependencies that aren't in development + +-r base.txt + +gunicorn==21.2.0 # https://github.com/benoitc/gunicorn +psycopg[c]==3.1.14 # https://github.com/psycopg/psycopg +Collectfast==2.2.0 # https://github.com/antonagestam/collectfast +sentry-sdk==1.38.0 # https://github.com/getsentry/sentry-python + +# Django +# ------------------------------------------------------------------------------ +django-storages[s3]==1.14.2 # https://github.com/jschneier/django-storages +django-anymail==10.2 # https://github.com/anymail/django-anymail diff --git a/scripts/001_group_creator.py b/scripts/001_group_creator.py new file mode 100644 index 00000000..d5d0f727 --- /dev/null +++ b/scripts/001_group_creator.py @@ -0,0 +1,29 @@ +from django.contrib.auth.models import Group, Permission + + +def group_creators(): + # if it has data do not any work + groups = Group.objects.first() + if groups: + return + + # user permissions + user_permissions = Permission.objects.filter(codename__in=["add_user", "change_user", "delete_user", "view_user"]) + + # task permissions + task_permissions = Permission.objects.filter(codename__in=["add_task", "change_task", "delete_task", "view_task"]) + + # add admin group in database + admin_group = Group.objects.create( + name="Admin", + ) + admin_group.permissions.add(*user_permissions, *task_permissions) + + # add user group in database + user_group = Group.objects.create( + name="User", + ) + user_group.permissions.add(*task_permissions) + + +group_creators() diff --git a/scripts/002_user_creator.py b/scripts/002_user_creator.py new file mode 100644 index 00000000..2a00dc07 --- /dev/null +++ b/scripts/002_user_creator.py @@ -0,0 +1,43 @@ +import logging + +from django.contrib.auth.models import Group + +from core.users.models import User + + +def add_users(): + try: + administrator_user = User.objects.create( + username="administrator", + email="sajjad.eb@gmail.com", + first_name="Sajjad", + last_name="Ebrahimi", + is_staff=True, + is_superuser=True, + ) + administrator_user.set_password("123") + administrator_user.save() + logging.info("Created administrator user") + + admin_user = User.objects.create( + username="admin", email="sina@webneshin.com", first_name="Siba", last_name="Kalan", is_staff=True + ) + admin_user.set_password("123") + admin_user.save() + logging.info("Created admin user") + admin_user.groups.add(Group.objects.get(name="Admin")) + logging.info("add Admin Group") + + user_user = User.objects.create( + username="user", email="nima@webneshin.com", first_name="Nima", last_name="Kabo", is_staff=True + ) + user_user.set_password("123") + user_user.save() + logging.info("Created user user") + user_user.groups.add(Group.objects.get(name="User")) + logging.info("add User Group") + except Exception as e: + print(e) + + +add_users() diff --git a/scripts/003_set_celery_beat.py b/scripts/003_set_celery_beat.py new file mode 100644 index 00000000..d09bba36 --- /dev/null +++ b/scripts/003_set_celery_beat.py @@ -0,0 +1,20 @@ +from django_celery_beat.models import CrontabSchedule, PeriodicTask + +scheduler_every_minute = CrontabSchedule.objects.filter( + minute="*", + hour="*", + day_of_week="*", + day_of_month="*", +).first() + +if not scheduler_every_minute: + scheduler_every_minute = CrontabSchedule.objects.create( + minute="*", + hour="*", + day_of_week="*", + day_of_month="*", + ) + +PeriodicTask.objects.get_or_create( + name="send email every minute", task="task.tasks.send_tasks_email", crontab_id=scheduler_every_minute.id +) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..2412f174 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +# flake8 and pycodestyle don't support pyproject.toml +# https://github.com/PyCQA/flake8/issues/234 +# https://github.com/PyCQA/pycodestyle/issues/813 +[flake8] +max-line-length = 119 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv +extend-ignore = E203 + +[pycodestyle] +max-line-length = 119 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv diff --git a/task/__init__.py b/task/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/task/admin.py b/task/admin.py new file mode 100644 index 00000000..d09e91f9 --- /dev/null +++ b/task/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from core.users.models import User +from task.models import Task + + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + ordering = ("-created",) + list_filter = ("owner",) + list_display = [ + "title", + "owner", + "send_time_schedule", + "send_time_done", + "parent", + ] + search_fields = [ + "id", + "title", + "owner__username", + "owner__email", + "owner__first_name", + "owner__last_name", + "description", + ] + + def get_form(self, request, obj=None, **kwargs): + user = request.user + form = super().get_form(request, obj, **kwargs) + if user.groups.filter(name="User"): + form.base_fields["owner"].queryset = User.objects.filter(id=user.id) + return form + + def get_queryset(self, request): + if request.user.groups.filter(name="User"): + return super().get_queryset(self).filter(owner=request.user) + return super().get_queryset(self) diff --git a/task/apps.py b/task/apps.py new file mode 100644 index 00000000..63b3e165 --- /dev/null +++ b/task/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TaskConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "task" diff --git a/task/migrations/0001_initial.py b/task/migrations/0001_initial.py new file mode 100644 index 00000000..30e1b8ec --- /dev/null +++ b/task/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.8 on 2023-12-07 14:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Task", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "id", + model_utils.fields.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("title", models.CharField(max_length=256)), + ("description", models.TextField(blank=True, null=True)), + ("send_time_schedule", models.DateField()), + ("send_time_done", models.DateField(blank=True, null=True)), + ("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="preconditions", + to="task.task", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/task/migrations/0002_alter_task_options_alter_task_send_time_done_and_more.py b/task/migrations/0002_alter_task_options_alter_task_send_time_done_and_more.py new file mode 100644 index 00000000..a03823e4 --- /dev/null +++ b/task/migrations/0002_alter_task_options_alter_task_send_time_done_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.8 on 2023-12-08 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("task", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="task", + options={"ordering": ["-created"]}, + ), + migrations.AlterField( + model_name="task", + name="send_time_done", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="task", + name="send_time_schedule", + field=models.DateTimeField(), + ), + ] diff --git a/task/migrations/__init__.py b/task/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/task/models.py b/task/models.py new file mode 100644 index 00000000..c709895c --- /dev/null +++ b/task/models.py @@ -0,0 +1,67 @@ +from django.core.mail import send_mail +from django.db import models +from django.utils.timezone import now + +from config.settings.base import MAX_SECONDS_TO_DONE_TASK +from core.models import BaseModel, nb +from core.users.models import OwnedModel + + +class Task(BaseModel, OwnedModel): + title = models.CharField( + max_length=256, + ) + description = models.TextField( + **nb, + ) + send_time_schedule = models.DateTimeField() + send_time_done = models.DateTimeField(**nb) + parent = models.ForeignKey( + to="self", + on_delete=models.CASCADE, + related_name="preconditions", + **nb, + ) + + @property + def is_self_done(self): + if self.send_time_done: + delta = (self.send_time_done - self.send_time_schedule).total_seconds() + if abs(delta) <= MAX_SECONDS_TO_DONE_TASK: + return True + + @property + def is_preconditions_done(self): + for task in self.preconditions.all(): + if not task.is_self_done: + return False + return True + + @property + def is_done(self): + if self.is_self_done and self.is_preconditions_done: + return True + + def send_email(self): + if self.is_self_done: + return False + if not self.is_preconditions_done: + return False + try: + send_mail( + "Subject here", + "Here is the message.", + "from@example.com", + [self.owner.email], + ) + self.send_time_done = now() + self.save() + except Exception as e: + # Will handle with sentry + raise e + + def __str__(self): + return f"{self.title} ({self.owner.username})" + + class Meta: + ordering = ["-created"] diff --git a/task/sample1.json b/task/sample1.json new file mode 100644 index 00000000..8be7a2d9 --- /dev/null +++ b/task/sample1.json @@ -0,0 +1,29 @@ +[ + { + "id": 1, + "title": "task 1", + "description": "desc 1", + "owner": "nilva.man", + "time to send": "2020-05-10 10:30", + "pre-tasks": [] + }, + { + "id": 2, + "title": "task 2", + "description": "desc 2", + "owner": "nilva.man", + "time to send": "2020-05-06 10:30", + "pre-tasks": [ + 1, + 3 + ] + }, + { + "id": 3, + "title": "task 3", + "description": "desc 3", + "owner": "nilva.man", + "time to send": "2020-02-10 9:30", + "pre-tasks": [] + } +] diff --git a/task/sample2.json b/task/sample2.json new file mode 100644 index 00000000..d9068654 --- /dev/null +++ b/task/sample2.json @@ -0,0 +1,31 @@ +[ + { + "id": 1, + "title": "task 1", + "description": "desc 1", + "owner": "nilva.man", + "time to send": "2020-05-10 10:30", + "pre-tasks": [] + }, + { + "id": 2, + "title": "task 2", + "description": "desc 2", + "owner": "nilva.man", + "time to send": "2020-06-10 12:30", + "pre-tasks": [ + 1, + 3 + ] + }, + { + "id": 3, + "title": "task 3", + "description": "desc 3", + "owner": "nilva.man", + "time to send": "2020-06-01 12:30", + "pre-tasks": [ + 1 + ] + } +] diff --git a/task/serializers.py b/task/serializers.py new file mode 100644 index 00000000..5e924b6d --- /dev/null +++ b/task/serializers.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class TaskValidatorSerializer(serializers.Serializer): + json = serializers.JSONField() diff --git a/task/tasks.py b/task/tasks.py new file mode 100644 index 00000000..a37f005e --- /dev/null +++ b/task/tasks.py @@ -0,0 +1,31 @@ +from datetime import timedelta +from uuid import UUID + +from django.utils.timezone import now + +from config import celery_app +from task.models import Task + + +@celery_app.task() +def send_task_email(task_id: UUID): + task = Task.objects.get(id=task_id) + task.send_email() + return {"status": True} + + +@celery_app.task() +def send_tasks_email(): + now_time = now() + min_time = now_time - timedelta(seconds=60) + max_time = now_time + timedelta(seconds=60) + tasks = Task.objects.filter( + send_time_done__isnull=True, + send_time_schedule__gte=min_time, + send_time_schedule__lte=max_time, + ) + for task in tasks: + # send_task_email.delay(task) + send_task_email.apply_async([task.id]) + + return {"count": len(tasks)} diff --git a/task/tests.py b/task/tests.py new file mode 100644 index 00000000..e69de29b diff --git a/task/urls.py b/task/urls.py new file mode 100644 index 00000000..67ee40e7 --- /dev/null +++ b/task/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from task.views import TaskValidatorAPIView + +urlpatterns = [ + path("validator", TaskValidatorAPIView.as_view(), name="task_validator"), +] + +# if settings.DEBUG: +# router = DefaultRouter() +# else: +# router = SimpleRouter() + +# router.register("task", MainCatViewSet) + +# app_name = "task" +# urlpatterns += router.urls diff --git a/task/validators.py b/task/validators.py new file mode 100644 index 00000000..b9dfbf9b --- /dev/null +++ b/task/validators.py @@ -0,0 +1,10 @@ +def task_validators(data): + data_sorted = sorted(data, key=lambda x: x["time to send"]) + task_id_can_done = [] + for task in data_sorted: + pre_tasks = [x for x in task["pre-tasks"] if x not in task_id_can_done] + if not pre_tasks: + task_id_can_done.append(task["id"]) + else: + return False + return True diff --git a/task/views.py b/task/views.py new file mode 100644 index 00000000..37cf9355 --- /dev/null +++ b/task/views.py @@ -0,0 +1,34 @@ +from rest_framework import exceptions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from task.serializers import TaskValidatorSerializer +from task.validators import task_validators + + +class TaskValidatorAPIView(APIView): + serializer_class = TaskValidatorSerializer + + # def get_serializer_class(self): + # if self.action == "post": + # return TaskValidatorSerializer + + def post(self, request): + # serializing + serializer = self.serializer_class(data=request.data) + + # validate serializer + serializer.is_valid(raise_exception=True) + data = serializer.data + + try: + list_tasks: list = data["json"] + if not list_tasks: + raise exceptions.ParseError("json is empty.") + + result = task_validators(list_tasks) + + except Exception: + raise exceptions.ParseError("json is note valid.") + + return Response({"result": result}, status=status.HTTP_200_OK) diff --git a/tests/test_merge_production_dotenvs_in_dotenv.py b/tests/test_merge_production_dotenvs_in_dotenv.py new file mode 100644 index 00000000..c0e68f60 --- /dev/null +++ b/tests/test_merge_production_dotenvs_in_dotenv.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import pytest + +from merge_production_dotenvs_in_dotenv import merge + + +@pytest.mark.parametrize( + ("input_contents", "expected_output"), + [ + ([], ""), + ([""], "\n"), + (["JANE=doe"], "JANE=doe\n"), + (["SEP=true", "AR=ator"], "SEP=true\nAR=ator\n"), + (["A=0", "B=1", "C=2"], "A=0\nB=1\nC=2\n"), + (["X=x\n", "Y=y", "Z=z\n"], "X=x\n\nY=y\nZ=z\n\n"), + ], +) +def test_merge( + tmp_path: Path, + input_contents: list[str], + expected_output: str, +): + output_file = tmp_path / ".env" + + files_to_merge = [] + for num, input_content in enumerate(input_contents, start=1): + merge_file = tmp_path / f".service{num}" + merge_file.write_text(input_content) + files_to_merge.append(merge_file) + + merge(output_file, files_to_merge) + + assert output_file.read_text() == expected_output