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"
+"a>."
+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"
+"a>."
+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