├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── ci.yml │ └── commit-lint.yml ├── .gitignore ├── .gitlint ├── .pre-commit-config.yaml ├── .pylintrc ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── apps ├── __init__.py ├── api │ ├── __init__.py │ ├── apps.py │ ├── codes.py │ ├── migrations │ │ └── __init__.py │ ├── pagination.py │ ├── response.py │ ├── serializers.py │ └── urls.py ├── authentication │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── authenticate.py │ ├── codes.py │ ├── config.py │ ├── constants.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── param.py │ ├── serializers.py │ ├── services.py │ ├── urls.py │ └── views.py └── common │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ └── __init__.py │ ├── models.py │ ├── raising.py │ └── validations.py ├── compose-development.yml ├── compose.yml ├── config ├── __init__.py ├── asgi.py ├── celery_conf.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── development.py │ ├── production.py │ └── test.py ├── urls.py └── wsgi.py ├── docs ├── commit-conventions.md ├── pre-commit.md ├── pylint.md └── rich_error.md ├── entrypoint.py ├── environment.py ├── example └── __init__.py ├── manage.py ├── nginx.conf ├── pkg ├── __init__.py ├── client │ ├── __init__.py │ ├── client.py │ └── tests.py ├── email │ ├── __init__.py │ ├── base.py │ ├── django_mail │ │ ├── __init__.py │ │ └── email.py │ └── email.py ├── file.py ├── logger │ ├── __init__.py │ ├── base.py │ ├── constants.py │ ├── logger.py │ └── standard │ │ ├── __init__.py │ │ └── logger.py ├── randomly.py └── rich_error │ ├── __init__.py │ ├── constant.py │ ├── error.py │ └── tests │ ├── __init__.py │ └── tests.py ├── readthedocs.yml ├── redis.conf └── requirements ├── base.txt ├── development.txt ├── production.txt └── test.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | docs 2 | .github 3 | .gitignore 4 | .gitlint 5 | docker-compose.yml 6 | compose-development.yml 7 | LICENSE 8 | Makefile 9 | README.md 10 | readthedocs.yml 11 | nginx.conf 12 | redis.conf -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECURITY_KEY='Django Secret Key, you can generate with get_random_secret_key() function from django.core.management.utils.' 2 | 3 | EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend' 4 | EMAIL_HOST='smtp.gmail.com' 5 | EMAIL_PORT=465 6 | EMAIL_HOST_USER='syntaxfa@gmail.com' 7 | EMAIL_HOST_PASSWORD='pass' 8 | 9 | ALLOWED_HOSTS='localhost,127.0.0.1' 10 | 11 | DJANGO_ENV=development 12 | 13 | DJANGO_PORT=8000 14 | 15 | REDIS_HOST=redis_syntax_host 16 | REDIS_HOST_DEBUG=localhost 17 | REDIS_PASSWORD=redis_syntax_password 18 | REDIS_PORT=6755 19 | 20 | CELERY_PREFETCH_MULTIPLIER=8 21 | 22 | LOGGER_NAME=standard 23 | 24 | POSTGRES_HOST=syntax_postgres_host 25 | POSTGRES_HOST_DEBUG=localhost 26 | POSTGRES_DB_NAME=postgres 27 | POSTGRES_USERNAME=username 28 | POSTGRES_PASSWORD=password 29 | POSTGRES_PORT=5699 30 | POSTGRES_CONN_MAX_AGE=600 31 | 32 | JWT_ACCESS_TOKEN_LIFETIME_IN_SECONDS=120 33 | JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS=90 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, reopened, synchronize, ready_for_review] 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Setup python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.13' 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r requirements/test.txt 21 | 22 | - name: Set up .env file 23 | run: | 24 | echo "SECURITY_KEY=django_secret_key_example" >> .env 25 | echo "EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend" >> .env 26 | echo "EMAIL_HOST=django.core.mail.backends.console.EmailBackend" >> .env 27 | echo "EMAIL_BACKEND=smtp.gmail.com" >> .env 28 | echo "EMAIL_PORT=465" >> .env 29 | echo "EMAIL_HOST_USER=syntaxfa@gmail.com" >> .env 30 | echo "EMAIL_HOST_PASSWORD=smpassword" >> .env 31 | echo "ALLOWED_HOSTS='localhost, 127.0.0.1'" >> .env 32 | echo "DJANGO_ENV=test" >> .env 33 | echo "DJANGO_PORT=8000" >> .env 34 | echo "REDIS_HOST=redis_syntax_host" >> .env 35 | echo "REDIS_HOST_DEBUG=localhost" >> .env 36 | echo "REDIS_PASSWORD=redis_syntax_password" >> .env 37 | echo "REDIS_PORT=6755" >> .env 38 | echo "CELERY_PREFETCH_MULTIPLIER=2" >> .env 39 | echo "LOGGER_NAME=standard" >> .env 40 | echo "POSTGRES_HOST=ghestat_postgres_host" >> .env 41 | echo "POSTGRES_HOST_DEBUG=localhost" >> .env 42 | echo "POSTGRES_DB_NAME=postgres" >> .env 43 | echo "POSTGRES_USERNAME=username" >> .env 44 | echo "POSTGRES_PASSWORD=password" >> .env 45 | echo "POSTGRES_PORT=5699" >> .env 46 | echo "POSTGRES_CONN_MAX_AGE=600" >> .env 47 | echo "JWT_ACCESS_TOKEN_LIFETIME_IN_SECONDS=120" >> .env 48 | echo "JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS=90" >> .env 49 | shell: bash 50 | 51 | - name: dockerUp 52 | run: sudo make docker-test-up 53 | 54 | - name: Test 55 | run: make test 56 | 57 | - name: dockerDown 58 | run: sudo make docker-test-down 59 | 60 | - name: Lint 61 | run: make lint -------------------------------------------------------------------------------- /.github/workflows/commit-lint.yml: -------------------------------------------------------------------------------- 1 | name: PR Commit Lint 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, reopened, synchronize, ready_for_review] 6 | branches: [main] 7 | 8 | permissions: write-all 9 | 10 | jobs: 11 | pr-lint: 12 | name: Validate PR commit title meets commit convention 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@v5.5.3 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | validateSingleCommit: false 20 | validateSingleCommitMatchesPrTitle: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/django,python,pycharm,git 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=django,python,pycharm,git 3 | 4 | ### Django ### 5 | *.log 6 | *.pot 7 | *.pyc 8 | __pycache__/ 9 | local_settings.py 10 | db.sqlite3 11 | db.sqlite3-journal 12 | media 13 | 14 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 15 | # in your Git repository. Update and uncomment the following line accordingly. 16 | # /staticfiles/ 17 | 18 | ### Django.Python Stack ### 19 | # Byte-compiled / optimized / DLL files 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Django stuff: 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | .pybuilder/ 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | # For a library or package, you might want to ignore these files since the code is 99 | # intended to run in multiple environments; otherwise, check them in: 100 | # .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # poetry 110 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 111 | # This is especially recommended for binary packages to ensure reproducibility, and is more 112 | # commonly ignored for libraries. 113 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 114 | #poetry.lock 115 | 116 | # pdm 117 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 118 | #pdm.lock 119 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 120 | # in version control. 121 | # https://pdm.fming.dev/#use-with-ide 122 | .pdm.toml 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | ### Git ### 175 | # Created by git for backups. To disable backups in Git: 176 | # $ git config --global mergetool.keepBackup false 177 | *.orig 178 | 179 | # Created by git when using merge tools for conflicts 180 | *.BACKUP.* 181 | *.BASE.* 182 | *.LOCAL.* 183 | *.REMOTE.* 184 | *_BACKUP_*.txt 185 | *_BASE_*.txt 186 | *_LOCAL_*.txt 187 | *_REMOTE_*.txt 188 | 189 | ### PyCharm ### 190 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 191 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 192 | 193 | # User-specific stuff 194 | .idea/**/workspace.xml 195 | .idea/**/tasks.xml 196 | .idea/**/usage.statistics.xml 197 | .idea/**/dictionaries 198 | .idea/**/shelf 199 | 200 | # AWS User-specific 201 | .idea/**/aws.xml 202 | 203 | # Generated files 204 | .idea/**/contentModel.xml 205 | 206 | # Sensitive or high-churn files 207 | .idea/**/dataSources/ 208 | .idea/**/dataSources.ids 209 | .idea/**/dataSources.local.xml 210 | .idea/**/sqlDataSources.xml 211 | .idea/**/dynamic.xml 212 | .idea/**/uiDesigner.xml 213 | .idea/**/dbnavigator.xml 214 | 215 | # Gradle 216 | .idea/**/gradle.xml 217 | .idea/**/libraries 218 | 219 | # Gradle and Maven with auto-import 220 | # When using Gradle or Maven with auto-import, you should exclude module files, 221 | # since they will be recreated, and may cause churn. Uncomment if using 222 | # auto-import. 223 | # .idea/artifacts 224 | # .idea/compiler.xml 225 | # .idea/jarRepositories.xml 226 | # .idea/modules.xml 227 | # .idea/*.iml 228 | # .idea/modules 229 | # *.iml 230 | # *.ipr 231 | 232 | # CMake 233 | cmake-build-*/ 234 | 235 | # Mongo Explorer plugin 236 | .idea/**/mongoSettings.xml 237 | 238 | # File-based project format 239 | *.iws 240 | 241 | # IntelliJ 242 | out/ 243 | 244 | # mpeltonen/sbt-idea plugin 245 | .idea_modules/ 246 | 247 | # JIRA plugin 248 | atlassian-ide-plugin.xml 249 | 250 | # Cursive Clojure plugin 251 | .idea/replstate.xml 252 | 253 | # SonarLint plugin 254 | .idea/sonarlint/ 255 | 256 | # Crashlytics plugin (for Android Studio and IntelliJ) 257 | com_crashlytics_export_strings.xml 258 | crashlytics.properties 259 | crashlytics-build.properties 260 | fabric.properties 261 | 262 | # Editor-based Rest Client 263 | .idea/httpRequests 264 | 265 | # Android studio 3.1+ serialized cache file 266 | .idea/caches/build_file_checksums.ser 267 | 268 | ### PyCharm Patch ### 269 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 270 | 271 | # *.iml 272 | # modules.xml 273 | # .idea/misc.xml 274 | # *.ipr 275 | 276 | # Sonarlint plugin 277 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 278 | .idea/**/sonarlint/ 279 | 280 | # SonarQube Plugin 281 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 282 | .idea/**/sonarIssues.xml 283 | 284 | # Markdown Navigator plugin 285 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 286 | .idea/**/markdown-navigator.xml 287 | .idea/**/markdown-navigator-enh.xml 288 | .idea/**/markdown-navigator/ 289 | 290 | # Cache file creation bug 291 | # See https://youtrack.jetbrains.com/issue/JBR-2257 292 | .idea/$CACHE_FILE$ 293 | 294 | # CodeStream plugin 295 | # https://plugins.jetbrains.com/plugin/12206-codestream 296 | .idea/codestream.xml 297 | 298 | # Azure Toolkit for IntelliJ plugin 299 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 300 | .idea/**/azureSettings.xml 301 | 302 | ### Python ### 303 | # Byte-compiled / optimized / DLL files 304 | 305 | # C extensions 306 | 307 | # Distribution / packaging 308 | 309 | # PyInstaller 310 | # Usually these files are written by a python script from a template 311 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 312 | 313 | # Installer logs 314 | 315 | # Unit test / coverage reports 316 | 317 | # Translations 318 | 319 | # Django stuff: 320 | 321 | # Flask stuff: 322 | 323 | # Scrapy stuff: 324 | 325 | # Sphinx documentation 326 | 327 | # PyBuilder 328 | 329 | # Jupyter Notebook 330 | 331 | # IPython 332 | 333 | # pyenv 334 | # For a library or package, you might want to ignore these files since the code is 335 | # intended to run in multiple environments; otherwise, check them in: 336 | # .python-version 337 | 338 | # pipenv 339 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 340 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 341 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 342 | # install all needed dependencies. 343 | 344 | # poetry 345 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 346 | # This is especially recommended for binary packages to ensure reproducibility, and is more 347 | # commonly ignored for libraries. 348 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 349 | 350 | # pdm 351 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 352 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 353 | # in version control. 354 | # https://pdm.fming.dev/#use-with-ide 355 | 356 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 357 | 358 | # Celery stuff 359 | 360 | # SageMath parsed files 361 | 362 | # Environments 363 | 364 | # Spyder project settings 365 | 366 | # Rope project settings 367 | 368 | # mkdocs documentation 369 | 370 | # mypy 371 | 372 | # Pyre type checker 373 | 374 | # pytype static type analyzer 375 | 376 | # Cython debug symbols 377 | 378 | # PyCharm 379 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 380 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 381 | # and can be added to the global gitignore or merged into this file. For a more nuclear 382 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 383 | 384 | ### Python Patch ### 385 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 386 | poetry.toml 387 | 388 | # ruff 389 | .ruff_cache/ 390 | 391 | # LSP config files 392 | pyrightconfig.json 393 | 394 | /.idea 395 | 396 | # End of https://www.toptal.com/developers/gitignore/api/django,python,pycharm,git 397 | 398 | db* 399 | 400 | note.txt 401 | 402 | static 403 | 404 | celerybeat-schedule* 405 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | [general] 2 | ignore=body-is-missing 3 | 4 | [title-max-length] 5 | line-length=72 6 | 7 | [title-min-length] 8 | min-length=3 9 | 10 | [title-match-regex] 11 | regex=^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+$ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: install-dependencies 5 | name: Install dependencies 6 | entry: bash -c 'pip install -r requirements/production.txt' 7 | language: system 8 | always_run: true 9 | 10 | - id: gitlint 11 | name: Run gitlint 12 | entry: gitlint 13 | language: python 14 | stages: [commit-msg] 15 | args: [--contrib=CT1, --msg-filename] 16 | 17 | - id: pylint 18 | name: Run pylint 19 | entry: pylint 20 | language: python 21 | types: [python] 22 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | # Analyse import fallback blocks. This can be used to support both Python 2 and 3 | # 3 compatible code, which means that the block might have code that exists 4 | # only in one or another interpreter, leading to false positives when analysed. 5 | analyse-fallback-blocks=no 6 | 7 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 8 | # in a server-like mode. 9 | clear-cache-post-run=no 10 | 11 | # Load and enable all available extensions. Use --list-extensions to see a list 12 | # all available extensions. 13 | #enable-all-extensions= 14 | 15 | # In error mode, messages with a category besides ERROR or FATAL are 16 | # suppressed, and no reports are done by default. Error mode is compatible with 17 | # disabling specific errors. 18 | #errors-only= 19 | 20 | # Always return a 0 (non-error) status code, even if lint errors are found. 21 | # This is primarily useful in continuous integration scripts. 22 | #exit-zero= 23 | 24 | # A comma-separated list of package or module names from where C extensions may 25 | # be loaded. Extensions are loading into the active Python interpreter and may 26 | # run arbitrary code. 27 | extension-pkg-allow-list= 28 | 29 | # A comma-separated list of package or module names from where C extensions may 30 | # be loaded. Extensions are loading into the active Python interpreter and may 31 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 32 | # for backward compatibility.) 33 | extension-pkg-whitelist= 34 | 35 | # Return non-zero exit code if any of these messages/categories are detected, 36 | # even if score is above --fail-under value. Syntax same as enable. Messages 37 | # specified are enabled, while categories only check already-enabled messages. 38 | fail-on= 39 | 40 | # Specify a score threshold under which the program will exit with error. 41 | fail-under=10 42 | 43 | # Interpret the stdin as a python script, whose filename needs to be passed as 44 | # the module_or_package argument. 45 | #from-stdin= 46 | 47 | # Files or directories to be skipped. They should be base names, not paths. 48 | ignore=CVS 49 | 50 | # Add files or directories matching the regular expressions patterns to the 51 | # ignore-list. The regex matches against paths and can be in Posix or Windows 52 | # format. Because '\\' represents the directory delimiter on Windows systems, 53 | # it can't be used as an escape character. 54 | ignore-paths=.*/migrations 55 | 56 | # Files or directories matching the regular expression patterns are skipped. 57 | # The regex matches against base names, not paths. The default value ignores 58 | # Emacs file locks 59 | ignore-patterns=^\.# 60 | 61 | # List of module names for which member attributes should not be checked and 62 | # will not be imported (useful for modules/projects where namespaces are 63 | # manipulated during runtime and thus existing member attributes cannot be 64 | # deduced by static analysis). It supports qualified module names, as well as 65 | # Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Resolve imports to .pyi stubs if available. May reduce no-member messages and 90 | # increase not-an-iterable messages. 91 | prefer-stubs=no 92 | 93 | # Minimum Python version to use for version dependent checks. Will default to 94 | # the version used to run pylint. 95 | py-version=3.11 96 | 97 | # Discover python modules and packages in the file system subtree. 98 | recursive=no 99 | 100 | # Add paths to the list of the source roots. Supports globbing patterns. The 101 | # source root is an absolute path or a path relative to the current working 102 | # directory used to determine a package namespace for modules located under the 103 | # source root. 104 | source-roots= 105 | 106 | # When enabled, pylint would attempt to guess common misconfiguration and emit 107 | # user-friendly hints instead of false-positive error messages. 108 | suggestion-mode=yes 109 | 110 | # Allow loading of arbitrary C extensions. Extensions are imported into the 111 | # active Python interpreter and may run arbitrary code. 112 | unsafe-load-any-extension=no 113 | 114 | # In verbose mode, extra non-checker-related info will be displayed. 115 | #verbose= 116 | 117 | 118 | [BASIC] 119 | 120 | # Naming style matching correct argument names. 121 | argument-naming-style=snake_case 122 | 123 | # Regular expression matching correct argument names. Overrides argument- 124 | # naming-style. If left empty, argument names will be checked with the set 125 | # naming style. 126 | #argument-rgx= 127 | 128 | # Naming style matching correct attribute names. 129 | attr-naming-style=snake_case 130 | 131 | # Regular expression matching correct attribute names. Overrides attr-naming- 132 | # style. If left empty, attribute names will be checked with the set naming 133 | # style. 134 | #attr-rgx= 135 | 136 | # Bad variable names which should always be refused, separated by a comma. 137 | bad-names=foo, 138 | bar, 139 | baz, 140 | toto, 141 | tutu, 142 | tata 143 | 144 | # Bad variable names regexes, separated by a comma. If names match any regex, 145 | # they will always be refused 146 | bad-names-rgxs= 147 | 148 | # Naming style matching correct class attribute names. 149 | class-attribute-naming-style=any 150 | 151 | # Regular expression matching correct class attribute names. Overrides class- 152 | # attribute-naming-style. If left empty, class attribute names will be checked 153 | # with the set naming style. 154 | #class-attribute-rgx= 155 | 156 | # Naming style matching correct class constant names. 157 | class-const-naming-style=UPPER_CASE 158 | 159 | # Regular expression matching correct class constant names. Overrides class- 160 | # const-naming-style. If left empty, class constant names will be checked with 161 | # the set naming style. 162 | #class-const-rgx= 163 | 164 | # Naming style matching correct class names. 165 | class-naming-style=PascalCase 166 | 167 | # Regular expression matching correct class names. Overrides class-naming- 168 | # style. If left empty, class names will be checked with the set naming style. 169 | #class-rgx= 170 | 171 | # Naming style matching correct constant names. 172 | const-naming-style=UPPER_CASE 173 | 174 | # Regular expression matching correct constant names. Overrides const-naming- 175 | # style. If left empty, constant names will be checked with the set naming 176 | # style. 177 | #const-rgx= 178 | 179 | # Minimum line length for functions/classes that require docstrings, shorter 180 | # ones are exempt. 181 | docstring-min-length=-1 182 | 183 | # Naming style matching correct function names. 184 | function-naming-style=snake_case 185 | 186 | # Regular expression matching correct function names. Overrides function- 187 | # naming-style. If left empty, function names will be checked with the set 188 | # naming style. 189 | #function-rgx= 190 | 191 | # Good variable names which should always be accepted, separated by a comma. 192 | good-names=i, 193 | j, 194 | k, 195 | ex, 196 | Run, 197 | _ 198 | 199 | # Good variable names regexes, separated by a comma. If names match any regex, 200 | # they will always be accepted 201 | good-names-rgxs= 202 | 203 | # Include a hint for the correct naming format with invalid-name. 204 | include-naming-hint=no 205 | 206 | # Naming style matching correct inline iteration names. 207 | inlinevar-naming-style=any 208 | 209 | # Regular expression matching correct inline iteration names. Overrides 210 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 211 | # with the set naming style. 212 | #inlinevar-rgx= 213 | 214 | # Naming style matching correct method names. 215 | method-naming-style=snake_case 216 | 217 | # Regular expression matching correct method names. Overrides method-naming- 218 | # style. If left empty, method names will be checked with the set naming style. 219 | #method-rgx= 220 | 221 | # Naming style matching correct module names. 222 | module-naming-style=snake_case 223 | 224 | # Regular expression matching correct module names. Overrides module-naming- 225 | # style. If left empty, module names will be checked with the set naming style. 226 | #module-rgx= 227 | 228 | # Colon-delimited sets of names that determine each other's naming style when 229 | # the name regexes allow several styles. 230 | name-group= 231 | 232 | # Regular expression which should only match function or class names that do 233 | # not require a docstring. 234 | no-docstring-rgx=^_ 235 | 236 | # List of decorators that produce properties, such as abc.abstractproperty. Add 237 | # to this list to register other decorators that produce valid properties. 238 | # These decorators are taken in consideration only for invalid-name. 239 | property-classes=abc.abstractproperty 240 | 241 | # Regular expression matching correct type alias names. If left empty, type 242 | # alias names will be checked with the set naming style. 243 | #typealias-rgx= 244 | 245 | # Regular expression matching correct type variable names. If left empty, type 246 | # variable names will be checked with the set naming style. 247 | #typevar-rgx= 248 | 249 | # Naming style matching correct variable names. 250 | variable-naming-style=snake_case 251 | 252 | # Regular expression matching correct variable names. Overrides variable- 253 | # naming-style. If left empty, variable names will be checked with the set 254 | # naming style. 255 | #variable-rgx= 256 | 257 | 258 | [CLASSES] 259 | 260 | # Warn about protected attribute access inside special methods 261 | check-protected-access-in-special-methods=no 262 | 263 | # List of method names used to declare (i.e. assign) instance attributes. 264 | defining-attr-methods=__init__, 265 | __new__, 266 | setUp, 267 | asyncSetUp, 268 | __post_init__ 269 | 270 | # List of member names, which should be excluded from the protected access 271 | # warning. 272 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 273 | 274 | # List of valid names for the first argument in a class method. 275 | valid-classmethod-first-arg=cls 276 | 277 | # List of valid names for the first argument in a metaclass class method. 278 | valid-metaclass-classmethod-first-arg=mcs 279 | 280 | 281 | [DESIGN] 282 | 283 | # List of regular expressions of class ancestor names to ignore when counting 284 | # public methods (see R0903) 285 | exclude-too-few-public-methods= 286 | 287 | # List of qualified class names to ignore when counting class parents (see 288 | # R0901) 289 | ignored-parents= 290 | 291 | # Maximum number of arguments for function / method. 292 | max-args=6 293 | 294 | # Maximum number of attributes for a class (see R0902). 295 | max-attributes=7 296 | 297 | # Maximum number of boolean expressions in an if statement (see R0916). 298 | max-bool-expr=5 299 | 300 | # Maximum number of branch for function / method body. 301 | max-branches=12 302 | 303 | # Maximum number of locals for function / method body. 304 | max-locals=15 305 | 306 | # Maximum number of parents for a class (see R0901). 307 | max-parents=7 308 | 309 | # Maximum number of positional arguments for function / method. 310 | max-positional-arguments=6 311 | 312 | # Maximum number of public methods for a class (see R0904). 313 | max-public-methods=20 314 | 315 | # Maximum number of return / yield for function / method body. 316 | max-returns=6 317 | 318 | # Maximum number of statements in function / method body. 319 | max-statements=50 320 | 321 | # Minimum number of public methods for a class (see R0903). 322 | min-public-methods=2 323 | 324 | 325 | [EXCEPTIONS] 326 | 327 | # Exceptions that will emit a warning when caught. 328 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 329 | 330 | 331 | [FORMAT] 332 | 333 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 334 | expected-line-ending-format= 335 | 336 | # Regexp for a line that is allowed to be longer than the limit. 337 | ignore-long-lines=^\s*(# )??$ 338 | 339 | # Number of spaces of indent required inside a hanging or continued line. 340 | indent-after-paren=4 341 | 342 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 343 | # tab). 344 | indent-string=' ' 345 | 346 | # Maximum number of characters on a single line. 347 | max-line-length=120 348 | 349 | # Maximum number of lines in a module. 350 | max-module-lines=1000 351 | 352 | # Allow the body of a class to be on the same line as the declaration if body 353 | # contains single statement. 354 | single-line-class-stmt=no 355 | 356 | # Allow the body of an if to be on the same line as the test if there is no 357 | # else. 358 | single-line-if-stmt=no 359 | 360 | 361 | [IMPORTS] 362 | 363 | # List of modules that can be imported at any level, not just the top level 364 | # one. 365 | allow-any-import-level= 366 | 367 | # Allow explicit reexports by alias from a package __init__. 368 | allow-reexport-from-package=no 369 | 370 | # Allow wildcard imports from modules that define __all__. 371 | allow-wildcard-with-all=no 372 | 373 | # Deprecated modules which should not be used, separated by a comma. 374 | deprecated-modules= 375 | 376 | # Output a graph (.gv or any supported image format) of external dependencies 377 | # to the given file (report RP0402 must not be disabled). 378 | ext-import-graph= 379 | 380 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 381 | # external) dependencies to the given file (report RP0402 must not be 382 | # disabled). 383 | import-graph= 384 | 385 | # Output a graph (.gv or any supported image format) of internal dependencies 386 | # to the given file (report RP0402 must not be disabled). 387 | int-import-graph= 388 | 389 | # Force import order to recognize a module as part of the standard 390 | # compatibility libraries. 391 | known-standard-library= 392 | 393 | # Force import order to recognize a module as part of a third party library. 394 | known-third-party=enchant 395 | 396 | # Couples of modules and preferred modules, separated by a comma. 397 | preferred-modules= 398 | 399 | 400 | [LOGGING] 401 | 402 | # The type of string formatting that logging methods do. `old` means using % 403 | # formatting, `new` is for `{}` formatting. 404 | logging-format-style=old 405 | 406 | # Logging modules to check that the string format arguments are in logging 407 | # function parameter format. 408 | logging-modules=logging 409 | 410 | 411 | [MESSAGES CONTROL] 412 | 413 | # Only show warnings with the listed confidence levels. Leave empty to show 414 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 415 | # UNDEFINED. 416 | confidence=HIGH, 417 | CONTROL_FLOW, 418 | INFERENCE, 419 | INFERENCE_FAILURE, 420 | UNDEFINED 421 | 422 | # Disable the message, report, category or checker with the given id(s). You 423 | # can either give multiple identifiers separated by comma (,) or put this 424 | # option multiple times (only on the command line, not in the configuration 425 | # file where it should appear only once). You can also use "--disable=all" to 426 | # disable everything first and then re-enable specific checks. For example, if 427 | # you want to run only the similarities checker, you can use "--disable=all 428 | # --enable=similarities". If you want to run only the classes checker, but have 429 | # no Warning level messages displayed, use "--disable=all --enable=classes 430 | # --disable=W". 431 | disable=raw-checker-failed, 432 | bad-inline-option, 433 | unsubscriptable-object, 434 | locally-disabled, 435 | file-ignored, 436 | suppressed-message, 437 | useless-suppression, 438 | deprecated-pragma, 439 | use-symbolic-message-instead, 440 | use-implicit-booleaness-not-comparison-to-string, 441 | use-implicit-booleaness-not-comparison-to-zero, 442 | missing-module-docstring, 443 | fixme, 444 | unused-import, 445 | missing-class-docstring, 446 | abstract-method, 447 | too-few-public-methods, 448 | missing-function-docstring, 449 | invalid-name, 450 | broad-exception-caught, 451 | bare-except, 452 | no-member, 453 | inconsistent-return-statements, 454 | missing-kwoa, 455 | too-many-instance-attributes 456 | 457 | # Enable the message, report, category or checker with the given id(s). You can 458 | # either give multiple identifier separated by comma (,) or put this option 459 | # multiple time (only on the command line, not in the configuration file where 460 | # it should appear only once). See also the "--disable" option for examples. 461 | enable= 462 | 463 | 464 | [METHOD_ARGS] 465 | 466 | # List of qualified names (i.e., library.method) which require a timeout 467 | # parameter e.g. 'requests.api.get,requests.api.post' 468 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 469 | 470 | 471 | [MISCELLANEOUS] 472 | 473 | # List of note tags to take in consideration, separated by a comma. 474 | notes=FIXME, 475 | XXX, 476 | TODO 477 | 478 | # Regular expression of note tags to take in consideration. 479 | notes-rgx= 480 | 481 | 482 | [REFACTORING] 483 | 484 | # Maximum number of nested blocks for function / method body 485 | max-nested-blocks=5 486 | 487 | # Complete name of functions that never returns. When checking for 488 | # inconsistent-return-statements if a never returning function is called then 489 | # it will be considered as an explicit return statement and no message will be 490 | # printed. 491 | never-returning-functions=sys.exit,argparse.parse_error 492 | 493 | # Let 'consider-using-join' be raised when the separator to join on would be 494 | # non-empty (resulting in expected fixes of the type: ``"- " + " - 495 | # ".join(items)``) 496 | suggest-join-with-non-empty-separator=yes 497 | 498 | 499 | [REPORTS] 500 | 501 | # Python expression which should return a score less than or equal to 10. You 502 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 503 | # 'convention', and 'info' which contain the number of messages in each 504 | # category, as well as 'statement' which is the total number of statements 505 | # analyzed. This score is used by the global evaluation report (RP0004). 506 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 507 | 508 | # Template used to display messages. This is a python new-style format string 509 | # used to format the message information. See doc for all details. 510 | msg-template= 511 | 512 | # Set the output format. Available formats are: text, parseable, colorized, 513 | # json2 (improved json format), json (old json format) and msvs (visual 514 | # studio). You can also give a reporter class, e.g. 515 | # mypackage.mymodule.MyReporterClass. 516 | #output-format= 517 | 518 | # Tells whether to display a full report or only the messages. 519 | reports=no 520 | 521 | # Activate the evaluation score. 522 | score=yes 523 | 524 | 525 | [SIMILARITIES] 526 | 527 | # Comments are removed from the similarity computation 528 | ignore-comments=yes 529 | 530 | # Docstrings are removed from the similarity computation 531 | ignore-docstrings=yes 532 | 533 | # Imports are removed from the similarity computation 534 | ignore-imports=yes 535 | 536 | # Signatures are removed from the similarity computation 537 | ignore-signatures=yes 538 | 539 | # Minimum lines number of a similarity. 540 | min-similarity-lines=4 541 | 542 | 543 | [SPELLING] 544 | 545 | # Limits count of emitted suggestions for spelling mistakes. 546 | max-spelling-suggestions=4 547 | 548 | # Spelling dictionary name. No available dictionaries : You need to install 549 | # both the python package and the system dependency for enchant to work. 550 | spelling-dict= 551 | 552 | # List of comma separated words that should be considered directives if they 553 | # appear at the beginning of a comment and should not be checked. 554 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 555 | 556 | # List of comma separated words that should not be checked. 557 | spelling-ignore-words= 558 | 559 | # A path to a file that contains the private dictionary; one word per line. 560 | spelling-private-dict-file= 561 | 562 | # Tells whether to store unknown words to the private dictionary (see the 563 | # --spelling-private-dict-file option) instead of raising a message. 564 | spelling-store-unknown-words=no 565 | 566 | 567 | [STRING] 568 | 569 | # This flag controls whether inconsistent-quotes generates a warning when the 570 | # character used as a quote delimiter is used inconsistently within a module. 571 | check-quote-consistency=no 572 | 573 | # This flag controls whether the implicit-str-concat should generate a warning 574 | # on implicit string concatenation in sequences defined over several lines. 575 | check-str-concat-over-line-jumps=no 576 | 577 | 578 | [TYPECHECK] 579 | 580 | # List of decorators that produce context managers, such as 581 | # contextlib.contextmanager. Add to this list to register other decorators that 582 | # produce valid context managers. 583 | contextmanager-decorators=contextlib.contextmanager 584 | 585 | # List of members which are set dynamically and missed by pylint inference 586 | # system, and so shouldn't trigger E1101 when accessed. Python regular 587 | # expressions are accepted. 588 | generated-members= 589 | 590 | # Tells whether to warn about missing members when the owner of the attribute 591 | # is inferred to be None. 592 | ignore-none=yes 593 | 594 | # This flag controls whether pylint should warn about no-member and similar 595 | # checks whenever an opaque object is returned when inferring. The inference 596 | # can return multiple potential results while evaluating a Python object, but 597 | # some branches might not be evaluated, which results in partial inference. In 598 | # that case, it might be useful to still emit no-member and other checks for 599 | # the rest of the inferred objects. 600 | ignore-on-opaque-inference=yes 601 | 602 | # List of symbolic message names to ignore for Mixin members. 603 | ignored-checks-for-mixins=no-member, 604 | not-async-context-manager, 605 | not-context-manager, 606 | attribute-defined-outside-init 607 | 608 | # List of class names for which member attributes should not be checked (useful 609 | # for classes with dynamically set attributes). This supports the use of 610 | # qualified names. 611 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 612 | 613 | # Show a hint with possible names when a member name was not found. The aspect 614 | # of finding the hint is based on edit distance. 615 | missing-member-hint=yes 616 | 617 | # The minimum edit distance a name should have in order to be considered a 618 | # similar match for a missing member name. 619 | missing-member-hint-distance=1 620 | 621 | # The total number of similar names that should be taken in consideration when 622 | # showing a hint for a missing member. 623 | missing-member-max-choices=1 624 | 625 | # Regex pattern to define which classes are considered mixins. 626 | mixin-class-rgx=.*[Mm]ixin 627 | 628 | # List of decorators that change the signature of a decorated function. 629 | signature-mutators= 630 | 631 | 632 | [VARIABLES] 633 | 634 | # List of additional names supposed to be defined in builtins. Remember that 635 | # you should avoid defining new builtins when possible. 636 | additional-builtins= 637 | 638 | # Tells whether unused global variables should be treated as a violation. 639 | allow-global-unused-variables=yes 640 | 641 | # List of names allowed to shadow builtins 642 | allowed-redefined-builtins= 643 | 644 | # List of strings which can identify a callback function by name. A callback 645 | # name must start or end with one of those strings. 646 | callbacks=cb_, 647 | _cb 648 | 649 | # A regular expression matching the name of dummy variables (i.e. expected to 650 | # not be used). 651 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 652 | 653 | # Argument names that match this expression will be ignored. 654 | ignored-argument-names=_.*|^ignored_|^unused_ 655 | 656 | # Tells whether we should check for unused import in __init__ files. 657 | init-import=no 658 | 659 | # List of qualified module names which can have objects that can redefine 660 | # builtins. 661 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 662 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim as builder 2 | 3 | LABEL name="alireza feizi" email="alirezafeyze44@gmail.com" 4 | LABEL "website.name"="syntaxfa" 5 | LABEL website="syntaxfa.com" 6 | LABEL desc="This is a python Dockerfile for django projects" 7 | 8 | # avoid stuck build due to user prompt 9 | ARG DEBIAN_FRONTEND=noninteractive 10 | 11 | RUN apt-get update && apt-get install -y --no-install-recommends && apt-get install -y gettext 12 | 13 | WORKDIR /code 14 | COPY . . 15 | 16 | RUN pip3 install --upgrade --no-cache-dir pip 17 | RUN pip3 install --no-cache-dir wheel 18 | RUN pip3 install --no-cache-dir -r requirements/production.txt 19 | 20 | ENV PYTHONUNBUFFERED=1 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 SyntaxFa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test 2 | 3 | ROOT=$(realpath $(dir $(lastword $(MAKEFILE_LIST)))) 4 | 5 | lint: 6 | pylint $(ROOT) 7 | 8 | test: 9 | python manage.py test 10 | 11 | docker-test-up: 12 | docker compose -f $(ROOT)/compose-development.yml up -d 13 | 14 | docker-test-down: 15 | docker compose -f $(ROOT)/compose-development.yml down 16 | 17 | docker-dev-up: 18 | docker compose -f $(ROOT)/compose-development.yml up -d 19 | 20 | docker-dev-down: 21 | docker compose -f $(ROOT)/compose-development.yml down 22 | 23 | docker-prod-up: 24 | docker compose up -d 25 | 26 | docker-prod-down: 27 | docker compose down 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Syntax Django Structure 2 | 3 | A robust and scalable Django project structure designed for building modern web applications. This structure emphasizes clean code, separation of concerns, and best practices for development and deployment. 4 | 5 | ----- 6 | 7 | ## Features 8 | 9 | * **Modular Architecture**: The project is organized into `apps` and `pkg` for better separation of concerns. Django apps handle specific business logic, while the `pkg` directory contains reusable packages. 10 | * **JWT Authentication**: Secure user authentication using JSON Web Tokens (JWT) with refresh and access tokens. It includes token verification, refresh, and logout (banning) functionalities. 11 | * **Rich Error Handling**: A custom `RichError` pattern for detailed and layered error reporting, which simplifies debugging and improves user feedback. 12 | * **Environment-based Settings**: Separate settings files for `development`, `production`, and `test` environments to manage configurations effectively. 13 | * **Dockerized Environment**: Comes with `docker-compose.yml` for both development and production, making it easy to set up and run the project with all its services (Postgres, Redis, Celery). 14 | * **Asynchronous Tasks**: Integrated with **Celery** and **Celery Beat** for handling background tasks and scheduled jobs. 15 | * **API Documentation**: Automatic API documentation generation with `drf-spectacular`, providing **Swagger** and **Redoc** UIs. 16 | * **Code Quality and Linting**: Pre-configured with `pre-commit` hooks for `pylint` and `gitlint` to ensure code quality and consistent commit messages. 17 | * **CI/CD Pipeline**: A basic CI pipeline is set up using GitHub Actions to run tests and linters on pull requests. 18 | * **Structured Logging**: A standardized logger is implemented to produce structured logs with relevant metadata for better monitoring and debugging. 19 | 20 | ----- 21 | 22 | ## Project Structure 23 | 24 | ``` 25 | ├── apps # Contains Django applications 26 | │ ├── api # Handles API routing, responses, and documentation 27 | │ ├── authentication # Manages user authentication and token handling 28 | │ └── common # Shared models, utilities, and validations 29 | ├── config # Project configuration files 30 | │ ├── settings # Environment-specific settings 31 | │ ├── celery_conf.py # Celery configuration 32 | │ └── urls.py # Root URL configuration 33 | ├── docs # Project documentation 34 | ├── pkg # Reusable Python packages 35 | │ ├── client # Utilities for client information (IP, device) 36 | │ ├── email # Email sending abstractions 37 | │ ├── logger # Structured logging implementation 38 | │ └── rich_error # Custom error handling package 39 | ├── requirements # Python dependencies 40 | ├── .github/workflows # GitHub Actions CI/CD workflows 41 | ├── compose.yml # Docker Compose for production 42 | ├── compose-development.yml # Docker Compose for development 43 | ├── Dockerfile # Dockerfile for the application 44 | ├── entrypoint.py # Entrypoint script for the Docker container 45 | └── manage.py # Django's command-line utility 46 | ``` 47 | 48 | ----- 49 | 50 | ## Getting Started 51 | 52 | ### Prerequisites 53 | 54 | * Docker 55 | * Docker Compose 56 | 57 | ### Installation 58 | 59 | 1. **Clone the repository:** 60 | 61 | ```shell 62 | git clone 63 | cd django-structure 64 | ``` 65 | 66 | 2. **Create an environment file:** 67 | Create a `.env` file in the root directory by copying the example. You will need to fill this out with your own credentials. 68 | 69 | 3. **Build and run the application using Docker Compose:** 70 | For a development environment, run: 71 | 72 | ```shell 73 | make docker-dev-up 74 | ``` 75 | 76 | This command, build the images and start the services. 77 | 78 | ----- 79 | 80 | ## Usage 81 | 82 | ### API Endpoints 83 | 84 | The API is versioned and accessible under `/api/v1/`. 85 | 86 | #### Authentication Endpoints 87 | 88 | * `POST /api/v1/auth/token/verify/`: Verify the validity of an access or refresh token. 89 | * `POST /api/v1/auth/token/refresh/`: Obtain a new access token using a refresh token. 90 | * `GET /api/v1/auth/token/logout/`: Ban the user's tokens, effectively logging them out from all devices. 91 | 92 | #### API Documentation 93 | 94 | * **Swagger UI**: ` /api/v1/schema/swagger/ ` 95 | * **Redoc**: `/api/v1/schema/redoc/` 96 | 97 | ### Running Tests 98 | 99 | To run the test suite, you can use the following command: 100 | 101 | ```shell 102 | make test 103 | ``` 104 | 105 | This will execute the tests inside the Docker container. Tests are located in the `tests.py` file within each package/app. 106 | 107 | ### Pre-commit Hooks 108 | 109 | This project uses `pre-commit` for code quality. To install the hooks: 110 | 111 | ```shell 112 | pre-commit install 113 | ``` 114 | 115 | The hooks will run automatically on every commit, ensuring that your code adheres to the project's standards. You can also run them manually on all files: 116 | 117 | ```shell 118 | pre-commit run --all-files 119 | ``` 120 | 121 | ----- 122 | 123 | ## Rich Error Handling 124 | 125 | This project uses a custom `RichError` pattern to provide detailed, multi-layered error information. This helps in debugging by preserving the context of an error as it propagates up the call stack. 126 | 127 | An error can be wrapped with additional context at each layer: 128 | 129 | ```python 130 | # Repository Layer 131 | raise RichError(operation="db.get_user").set_msg("User not found") 132 | 133 | # Service Layer 134 | try: 135 | # ... call repository 136 | except Exception as err: 137 | raise RichError(operation="service.process_user").set_error(err) 138 | ``` 139 | 140 | The `get_error_info` function can then be used to retrieve a list of all error layers for logging or structured responses. For more details, refer to the documentation in `docs/rich_error.md`. 141 | 142 | ----- 143 | 144 | ## Deployment 145 | 146 | The project is configured for deployment using **Docker**, **Nginx**, and **Gunicorn**. The `compose.yml` file defines the services for a production environment, including the application container, a Celery worker, a Celery beat scheduler, a Postgres database, and Redis. 147 | 148 | The `nginx.conf` file is a sample Nginx configuration that can be used as a reverse proxy to serve the Django application. 149 | 150 | The `entrypoint.py` script handles database migrations and starts the Gunicorn server with an appropriate number of workers based on the CPU cores available. 151 | 152 | ----- 153 | 154 | ## Dependencies 155 | 156 | Key dependencies are managed in the `requirements` directory. 157 | 158 | * **`base.txt`**: Core dependencies for the project, such as Django, Django REST Framework, and Simple JWT. 159 | * **`development.txt`**: Dependencies for the development environment, including `django-debug-toolbar` and `pylint`. 160 | * **`production.txt`**: Dependencies for the production environment, such as `gunicorn` and `gevent`. 161 | * **`test.txt`**: Dependencies for running tests, like `model-bakery`. -------------------------------------------------------------------------------- /apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/__init__.py -------------------------------------------------------------------------------- /apps/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/api/__init__.py -------------------------------------------------------------------------------- /apps/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class ApiConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'apps.api' 8 | verbose_name = _("API") 9 | -------------------------------------------------------------------------------- /apps/api/codes.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from apps.authentication.codes import CODE_TRANSLATION as AUTHENTICATION_CODE_TRANSLATION 4 | 5 | # 200_000 6 | OK = 200_000 7 | CREATED = 201_000 8 | # 500_000 9 | INTERNAL_SERVER_ERROR = 500_000 10 | 11 | CODE_TRANSLATION = { 12 | OK: _("OK"), 13 | CREATED: _("Created"), 14 | INTERNAL_SERVER_ERROR: _("Internal Server Error"), 15 | **AUTHENTICATION_CODE_TRANSLATION, # authentication code starts with 100 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/api/migrations/__init__.py -------------------------------------------------------------------------------- /apps/api/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination as _PageNumberPagination 2 | from rest_framework import serializers 3 | 4 | 5 | class PageNumberPagination(_PageNumberPagination): 6 | max_page_size = 100 7 | page_size = 35 8 | page_size_query_param = 'page_size' 9 | 10 | 11 | class ListResponse(serializers.Serializer): 12 | count = serializers.IntegerField() 13 | next = serializers.CharField(default="next page link") 14 | previous = serializers.CharField(default="previous page link") 15 | -------------------------------------------------------------------------------- /apps/api/response.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | from rest_framework.response import Response 4 | from rest_framework.status import HTTP_200_OK 5 | from drf_spectacular.utils import OpenApiExample 6 | 7 | from pkg.rich_error.error import RichError, error_code 8 | from apps.api.codes import INTERNAL_SERVER_ERROR, CODE_TRANSLATION 9 | 10 | 11 | def _get_code(error: Exception): 12 | if isinstance(error, RichError): 13 | return error_code(error=error) 14 | 15 | return INTERNAL_SERVER_ERROR 16 | 17 | 18 | def base_response(*, status_code: int = HTTP_200_OK, result: Optional[Dict] = None, 19 | message: Optional[str] = None) -> Response: 20 | return Response( 21 | data={ 22 | "result": result, 23 | "message": message}, 24 | status=status_code) 25 | 26 | 27 | def base_response_with_error(error: Exception) -> Response: 28 | code = _get_code(error=error) 29 | return Response(data={"error": CODE_TRANSLATION[code]}, status=code // 1000) 30 | 31 | 32 | def error_open_api_example(message: str, error: str) -> OpenApiExample: 33 | return OpenApiExample( 34 | name=message, 35 | value={"error": error} 36 | ) 37 | -------------------------------------------------------------------------------- /apps/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class SuccessResponseSerializer(serializers.Serializer): 5 | message = serializers.CharField(required=True) 6 | result = serializers.CharField(required=True) 7 | 8 | 9 | class ErrorResponseSerializer(serializers.Serializer): 10 | message = serializers.CharField(required=True) 11 | error = serializers.CharField(required=True) 12 | -------------------------------------------------------------------------------- /apps/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView 4 | 5 | 6 | doc_urls = [ 7 | path("", SpectacularAPIView.as_view(), name="schema"), 8 | path("swagger/", SpectacularSwaggerView.as_view(), name="swagger"), 9 | path("redoc/", SpectacularRedocView.as_view(), name="redoc"), 10 | ] 11 | 12 | v1 = [ 13 | path("schema/", include(doc_urls), name="schema"), 14 | path("auth/", include("apps.authentication.urls")), 15 | ] 16 | 17 | urlpatterns = [ 18 | path("v1/", include(v1)), 19 | ] 20 | -------------------------------------------------------------------------------- /apps/authentication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/authentication/__init__.py -------------------------------------------------------------------------------- /apps/authentication/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import UserAuth 4 | 5 | 6 | @admin.register(UserAuth) 7 | class UserAuthAdmin(admin.ModelAdmin): 8 | list_display = ("id", "user_id", "created_at", "updated_at") 9 | search_fields = ("user_id",) 10 | -------------------------------------------------------------------------------- /apps/authentication/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class AuthenticationConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'apps.authentication' 8 | verbose_name = _("Authentication") 9 | -------------------------------------------------------------------------------- /apps/authentication/authenticate.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=raise-missing-from 2 | from typing import Optional, Tuple 3 | 4 | from django.contrib.auth import get_user_model 5 | from rest_framework.request import Request 6 | 7 | from rest_framework_simplejwt.authentication import AuthUser 8 | from rest_framework_simplejwt.authentication import JWTAuthentication as BaseJWTAuthentication 9 | from rest_framework_simplejwt.tokens import Token 10 | from rest_framework_simplejwt.exceptions import InvalidToken 11 | 12 | from apps.authentication.services import get_token_service 13 | from pkg.client.client import get_client_info 14 | 15 | User = get_user_model() 16 | service = get_token_service() 17 | 18 | 19 | class JWTAuthentication(BaseJWTAuthentication): 20 | 21 | def authenticate(self, request: Request) -> Optional[Tuple[AuthUser, Token]]: 22 | header = self.get_header(request) 23 | if header is None: 24 | return None 25 | 26 | raw_token = self.get_raw_token(header) 27 | if raw_token is None: 28 | return None 29 | 30 | try: 31 | service.validate_token(token_str=raw_token.decode(), client_info=get_client_info(request=request)) 32 | except Exception: 33 | raise InvalidToken() 34 | 35 | return super().authenticate(request=request) 36 | -------------------------------------------------------------------------------- /apps/authentication/codes.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | # authentication codes starts with 100 4 | 5 | # 401_00 6 | INVALID_TOKEN = 401_100 7 | INVALID_REFRESH_TOKEN = 401_101 8 | INVALID_ACCESS_TOKEN = 401_102 9 | 10 | CODE_TRANSLATION = { 11 | # 401_00 12 | INVALID_TOKEN: _("Invalid token"), 13 | INVALID_REFRESH_TOKEN: _("Invalid refresh token"), 14 | INVALID_ACCESS_TOKEN: _("Invalid access token"), 15 | } 16 | -------------------------------------------------------------------------------- /apps/authentication/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from django.conf import settings 4 | 5 | 6 | @dataclass 7 | class TokenConfig: 8 | prefix_user_auth_cache_key: str = "users:auths" 9 | user_auth_cache_exp_in_seconds: int = 86400 # one day 10 | access_token_lifetime: int = settings.JWT_ACCESS_TOKEN_LIFETIME_IN_SECONDS 11 | refresh_token_lifetime: int = settings.JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS 12 | -------------------------------------------------------------------------------- /apps/authentication/constants.py: -------------------------------------------------------------------------------- 1 | APP_NAME = "auth" 2 | 3 | AUTH_SERVICE = "auth_service" 4 | AUTH_SERVICE_GENERATE_TOKEN = "auth_service_generate_token" 5 | AUTH_SERVICE_VALIDATE_TOKEN = "auth_service_validate_token" 6 | AUTH_SERVICE_GET_USER_ID_FROM_TOKEN = "auth_service_get_user_id_from_token" 7 | AUTH_SERVICE_REFRESH_ACCESS_TOKEN = "auth_service_refresh_access_token" 8 | 9 | IP_ADDRESS = "ip_address" 10 | DEVICE_NAME = "device_name" 11 | -------------------------------------------------------------------------------- /apps/authentication/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2 on 2025-06-02 15:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='UserAuth', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('created_at', models.DateTimeField(auto_now_add=True)), 19 | ('updated_at', models.DateTimeField(auto_now=True)), 20 | ('user_id', models.PositiveBigIntegerField(db_index=True, unique=True, verbose_name='user id')), 21 | ('access_uuid', models.UUIDField(db_index=True, unique=True, verbose_name='access uuid')), 22 | ('refresh_uuid', models.UUIDField(db_index=True, unique=True, verbose_name='refresh uuid')), 23 | ], 24 | options={ 25 | 'verbose_name': 'User Auth', 26 | 'verbose_name_plural': 'User Auths', 27 | 'ordering': ('-id',), 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /apps/authentication/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/authentication/migrations/__init__.py -------------------------------------------------------------------------------- /apps/authentication/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from apps.common.models import BaseModel 5 | 6 | 7 | class UserAuth(BaseModel): 8 | user_id = models.PositiveBigIntegerField(verbose_name=_("user id"), db_index=True, unique=True) 9 | access_uuid = models.UUIDField(verbose_name=_("access uuid"), unique=True, db_index=True) 10 | refresh_uuid = models.UUIDField(verbose_name=_("refresh uuid"), unique=True, db_index=True) 11 | 12 | class Meta: 13 | verbose_name = _("User Auth") 14 | verbose_name_plural = _("User Auths") 15 | ordering = ("-id",) 16 | 17 | def __str__(self): 18 | return f"id: {self.pk} user id: {self.user_id}" 19 | -------------------------------------------------------------------------------- /apps/authentication/param.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class GenerateTokenResponse: 6 | refresh_token: str 7 | access_token: str 8 | expired_at: int 9 | -------------------------------------------------------------------------------- /apps/authentication/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apps.api.serializers import SuccessResponseSerializer 4 | 5 | 6 | class TokenSerializer(serializers.Serializer): 7 | token = serializers.CharField() 8 | 9 | 10 | class RefreshTokenSerializer(serializers.Serializer): 11 | refresh_token = serializers.CharField() 12 | 13 | 14 | class AccessTokenSerializer(serializers.Serializer): 15 | access_token = serializers.CharField() 16 | 17 | 18 | class GenerateTokenSerializer(serializers.Serializer): 19 | refresh_token = serializers.CharField(default="refresh_token") 20 | access_token = serializers.CharField(default="access_token") 21 | expired_at = serializers.IntegerField(default=120) 22 | 23 | 24 | class TokenVerifyResponseSerializer(SuccessResponseSerializer): 25 | message = serializers.CharField(default="Token successfully verified.") 26 | result = serializers.CharField(default="") 27 | 28 | 29 | class TokenRefreshResponseSerializer(SuccessResponseSerializer): 30 | message = serializers.CharField(default="Token refreshed.") 31 | result = GenerateTokenSerializer() 32 | 33 | 34 | class TokenBanResponseSerializer(SuccessResponseSerializer): 35 | message = serializers.CharField(default="user logged out.") 36 | result = serializers.CharField(default="") 37 | -------------------------------------------------------------------------------- /apps/authentication/services.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from functools import cache 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.core.cache import cache as django_cache 6 | from django.utils.timezone import now 7 | from rest_framework_simplejwt.tokens import RefreshToken, Token, AccessToken, UntypedToken 8 | 9 | from apps.authentication.config import TokenConfig 10 | from apps.authentication.models import UserAuth 11 | from apps.authentication.param import GenerateTokenResponse 12 | from apps.authentication import codes, constants 13 | from apps.common.raising import err_log 14 | from pkg.client.client import ClientInfo 15 | from pkg.rich_error.error import RichError 16 | 17 | 18 | class TokenService: 19 | Op = "authentication.services.TokenService." 20 | User = get_user_model() 21 | 22 | def __init__(self, config: TokenConfig): 23 | self.cfg = config 24 | 25 | @staticmethod 26 | def check_user_auth_is_exist(user_id: int) -> bool: 27 | return UserAuth.objects.filter(user_id=user_id).exists() 28 | 29 | @staticmethod 30 | def create_user_auth(user_id: int) -> UserAuth: 31 | return UserAuth.objects.create(user_id=user_id, access_uuid=uuid.uuid4(), refresh_uuid=uuid.uuid4()) 32 | 33 | @staticmethod 34 | def get_user_auth_by_user_id(user_id: int) -> UserAuth: 35 | return UserAuth.objects.get(user_id=user_id) 36 | 37 | @staticmethod 38 | def update_user_auth_access_uuid(user_id: int) -> int: 39 | return UserAuth.objects.filter(user_id=user_id).update(access_uuid=uuid.uuid4()) 40 | 41 | @staticmethod 42 | def update_user_auth_refresh_uuid(user_id: int) -> int: 43 | """ 44 | If we update refresh uuid, also we need to update access uuid. 45 | """ 46 | return UserAuth.objects.filter(user_id=user_id).update(refresh_uuid=uuid.uuid4(), access_uuid=uuid.uuid4()) 47 | 48 | def _get_user_auth_cache_key(self, user_id: int) -> str: 49 | return f"{self.cfg.prefix_user_auth_cache_key}:{user_id}" 50 | 51 | def get_user_auth(self, user_id: int) -> UserAuth: 52 | key = self._get_user_auth_cache_key(user_id=user_id) 53 | 54 | user_auth = django_cache.get(key=key) 55 | if not user_auth: 56 | if not self.check_user_auth_is_exist(user_id=user_id): 57 | user_auth = self.create_user_auth(user_id=user_id) 58 | else: 59 | user_auth = self.get_user_auth_by_user_id(user_id=user_id) 60 | django_cache.set(key=key, value=user_auth, timeout=self.cfg.user_auth_cache_exp_in_seconds) 61 | 62 | return user_auth 63 | 64 | def generate_token(self, user: User, client_info: ClientInfo) -> GenerateTokenResponse: 65 | meta = {"user_id": user.id} 66 | 67 | try: 68 | user_auth = self.get_user_auth(user_id=user.id) 69 | 70 | refresh: Token = RefreshToken.for_user(user=user) 71 | refresh[constants.IP_ADDRESS] = client_info.ip_address 72 | refresh[constants.DEVICE_NAME] = client_info.device_name 73 | 74 | access: AccessToken = refresh.access_token 75 | 76 | refresh["uuid"] = str(user_auth.refresh_uuid) 77 | access["uuid"] = str(user_auth.access_uuid) 78 | 79 | return GenerateTokenResponse( 80 | refresh_token=str(refresh), 81 | access_token=str(access), 82 | expired_at=self.cfg.access_token_lifetime, 83 | ) 84 | except Exception as err: 85 | err_log(constants.APP_NAME, err, constants.AUTH_SERVICE, 86 | constants.AUTH_SERVICE_GENERATE_TOKEN, meta=meta) 87 | raise err 88 | 89 | def _validate_refresh_token(self, token: Token, client_info: ClientInfo) -> None: 90 | op = self.Op + "_validate_refresh_token" 91 | meta = {"client_info": client_info.__dict__} 92 | 93 | user_auth = self.get_user_auth(user_id=token["user_id"]) 94 | if str(user_auth.refresh_uuid) != token["uuid"]: 95 | raise (RichError(op).set_msg("refresh token uuid don't math with user auth uuid"). 96 | set_code(codes.INVALID_REFRESH_TOKEN)).set_meta(meta) 97 | 98 | if token["device_name"] != client_info.device_name: 99 | raise (RichError(op).set_msg("refresh token device name don't match with request device name"). 100 | set_code(codes.INVALID_REFRESH_TOKEN)).set_meta(meta) 101 | 102 | def _validate_access_token(self, token: Token, client_info: ClientInfo) -> None: 103 | op = self.Op + "_validate_access_token" 104 | meta = {"client_info": client_info.__dict__} 105 | 106 | user_auth = self.get_user_auth(user_id=token["user_id"]) 107 | if str(user_auth.access_uuid) != token["uuid"]: 108 | raise (RichError(op).set_msg("access token uuid don't math with user auth uuid"). 109 | set_code(codes.INVALID_ACCESS_TOKEN)).set_meta(meta) 110 | 111 | if token["device_name"] != client_info.device_name or token["ip_address"] != client_info.ip_address: 112 | raise RichError(op).set_msg("access token device name or ip don't match with request info").set_meta(meta) 113 | 114 | def validate_token(self, token_str: str, client_info: ClientInfo) -> Token: 115 | op = self.Op + "validate_token" 116 | meta = {"token_str": token_str, "client_info": client_info.__dict__} 117 | 118 | try: 119 | try: 120 | token = UntypedToken(token=token_str) 121 | except Exception as err: 122 | raise RichError(op).set_error(err).set_msg("untyped token is not valid").set_code(codes.INVALID_TOKEN) 123 | 124 | if token["token_type"] == "refresh": 125 | self._validate_refresh_token(token=token, client_info=client_info) 126 | elif token["token_type"] == "access": 127 | self._validate_access_token(token=token, client_info=client_info) 128 | 129 | return token 130 | except Exception as err: 131 | err_log(constants.APP_NAME, err, constants.AUTH_SERVICE, 132 | constants.AUTH_SERVICE_VALIDATE_TOKEN, meta=meta) 133 | raise err 134 | 135 | def get_user_id_from_token(self, token_str: str) -> str: 136 | op = self.Op + "get_user_id_from_token" 137 | meta = {"token": token_str} 138 | 139 | try: 140 | try: 141 | token = UntypedToken(token=token_str) 142 | except Exception as err: 143 | raise RichError(op).set_error(err).set_msg("untyped token is not valid").set_code(codes.INVALID_TOKEN) 144 | 145 | return token["user_id"] 146 | except Exception as err: 147 | err_log(constants.APP_NAME, err, constants.AUTH_SERVICE, 148 | constants.AUTH_SERVICE_GET_USER_ID_FROM_TOKEN, meta=meta) 149 | raise err 150 | 151 | def refresh_access_token(self, refresh_token_str: str, client_info: ClientInfo) -> GenerateTokenResponse: 152 | op = self.Op + "refresh_access_token" 153 | meta = {"refresh_token_str": refresh_token_str, "client_info": client_info.__dict__} 154 | 155 | try: 156 | token = self.validate_token(token_str=refresh_token_str, client_info=client_info) 157 | if token["token_type"] != "refresh": 158 | raise RichError(op).set_msg("token type is not refresh token").set_code(codes.INVALID_REFRESH_TOKEN) 159 | 160 | user = self.User.objects.get(id=token["user_id"]) 161 | user.last_login = now() 162 | user.save() 163 | 164 | token = self.generate_token(user=user, client_info=client_info) 165 | 166 | return token 167 | except Exception as err: 168 | err_log(constants.APP_NAME, err, constants.AUTH_SERVICE, 169 | constants.AUTH_SERVICE_REFRESH_ACCESS_TOKEN, meta=meta) 170 | raise err 171 | 172 | def ban_token(self, user_id: int) -> None: 173 | self.update_user_auth_refresh_uuid(user_id=user_id) 174 | 175 | django_cache.delete(key=self._get_user_auth_cache_key(user_id=user_id)) 176 | 177 | def update_access_token_uuid(self, user_id: int) -> None: 178 | self.update_user_auth_access_uuid(user_id=user_id) 179 | 180 | 181 | @cache 182 | def get_token_service(): 183 | return TokenService( 184 | config=TokenConfig(), 185 | ) 186 | -------------------------------------------------------------------------------- /apps/authentication/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from apps.authentication.views import TokenBanView, TokenRefreshView, TokenVerifyView 4 | 5 | urlpatterns = [ 6 | path("token/", include(([ 7 | path("verify/", TokenVerifyView.as_view(), name="token-verify"), 8 | path("refresh/", TokenRefreshView.as_view(), name="token-refresh"), 9 | path("logout/", TokenBanView.as_view(), name="token-ban"), 10 | ] 11 | ))), 12 | ] 13 | -------------------------------------------------------------------------------- /apps/authentication/views.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.types import OpenApiTypes 2 | from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiRequest 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.views import APIView 5 | 6 | from apps.api.response import base_response, error_open_api_example, base_response_with_error 7 | from apps.authentication import codes 8 | from apps.authentication.serializers import TokenSerializer, AccessTokenSerializer, \ 9 | TokenVerifyResponseSerializer, TokenRefreshResponseSerializer, TokenBanResponseSerializer, RefreshTokenSerializer 10 | from apps.authentication.services import get_token_service 11 | from pkg.client.client import get_client_info 12 | 13 | SCHEMA_TAGS = ("Auth / Token",) 14 | service = get_token_service() 15 | 16 | 17 | class TokenVerifyView(APIView): 18 | serializer_class = TokenSerializer 19 | 20 | @extend_schema( 21 | request=OpenApiRequest(request=serializer_class), 22 | responses={ 23 | 200: OpenApiResponse(response=TokenVerifyResponseSerializer), 24 | 400: OpenApiResponse(response=serializer_class), 25 | 401: OpenApiResponse(response=OpenApiTypes.OBJECT, 26 | examples=[ 27 | error_open_api_example(message="Invalid token", error=codes.CODE_TRANSLATION[codes.INVALID_TOKEN]), 28 | ]), 29 | }, 30 | tags=SCHEMA_TAGS 31 | ) 32 | def post(self, request): 33 | """ 34 | ### Verify Token Validity 35 | 36 | This endpoint allows clients to check the validity of a given token (either an access token or a refresh token). 37 | It's useful for clients to confirm if their stored tokens are still active before making protected API calls. 38 | 39 | **Request Body:** 40 | * `token`: The token string to be validated. 41 | 42 | **Possible Responses:** 43 | * **200 OK**: The token is valid. 44 | * **400 Bad Request**: The request body is malformed, or the `token` field is missing/invalid. 45 | * **401 Unauthorized**: The token is invalid or has expired. 46 | """ 47 | serializer = self.serializer_class(data=request.data) 48 | serializer.is_valid(raise_exception=True) 49 | try: 50 | service.validate_token( 51 | token_str=serializer.validated_data["token"], client_info=get_client_info(request=request)) 52 | return base_response() 53 | except Exception as err: 54 | return base_response_with_error(err) 55 | 56 | 57 | class TokenRefreshView(APIView): 58 | serializer_class = RefreshTokenSerializer 59 | serializer_class_output = AccessTokenSerializer 60 | 61 | @extend_schema( 62 | request=OpenApiRequest(request=serializer_class), 63 | responses={ 64 | 200: OpenApiResponse(response=TokenRefreshResponseSerializer), 65 | 400: OpenApiResponse(response=serializer_class), 66 | 401: OpenApiResponse( 67 | response=OpenApiTypes.OBJECT, 68 | examples=[ 69 | error_open_api_example(message="Invalid token", error=codes.CODE_TRANSLATION[codes.INVALID_TOKEN]), 70 | ] 71 | ) 72 | }, 73 | tags=SCHEMA_TAGS) 74 | def post(self, request): 75 | """ 76 | ### Refresh Access Token 77 | 78 | This endpoint allows clients to obtain a new access token using a valid refresh token. 79 | This is crucial for maintaining user sessions without requiring them to re-authenticate frequently. 80 | The refresh token itself is also returned, indicating whether it was regenerated or simply reused. 81 | 82 | **Request Body:** 83 | * `token`: The refresh token string previously issued to the client. 84 | 85 | **Possible Responses:** 86 | * **200 OK**: A new access token and potentially a new refresh token are successfully issued. The response 87 | includes their values and the access token's expiry. 88 | * **400 Bad Request**: The request body is malformed, or the `token` (refresh token) field is missing, empty, 89 | or not a valid string. 90 | * **401 Unauthorized**: The refresh token is invalid, expired, revoked, or does not match server-side 91 | security checks (e.g., mismatched UUIDs, device name, or IP address). This also applies if the provided 92 | token is an access token instead of a refresh token. 93 | """ 94 | serializer = self.serializer_class(data=request.data) 95 | serializer.is_valid(raise_exception=True) 96 | try: 97 | resp = service.refresh_access_token(refresh_token_str=serializer.validated_data["refresh_token"], 98 | client_info=get_client_info(request=request)) 99 | return base_response(result=resp.__dict__, message="token refreshed") 100 | except Exception as err: 101 | return base_response_with_error(err) 102 | 103 | 104 | class TokenBanView(APIView): 105 | permission_classes = (IsAuthenticated,) 106 | 107 | @extend_schema( 108 | responses={ 109 | 200: OpenApiResponse(response=TokenBanResponseSerializer), 110 | 401: OpenApiResponse( 111 | response=OpenApiTypes.OBJECT, 112 | examples=[ 113 | error_open_api_example(message="Invalid token", error=codes.CODE_TRANSLATION[codes.INVALID_TOKEN]), 114 | ] 115 | ) 116 | }, 117 | tags=SCHEMA_TAGS) 118 | def get(self, request): 119 | """ 120 | ### Ban User Tokens (Logout from All Devices) 121 | 122 | This endpoint allows an authenticated user to invalidate all their active tokens, 123 | effectively logging them out from all devices where they are currently logged in. 124 | This action revokes both access and refresh tokens associated with the user. 125 | 126 | **Authentication:** 127 | * This endpoint requires an **authenticated user**. A valid access token must be provided in the 128 | `Authorization` header. 129 | 130 | **Request Body:** 131 | * This endpoint does not require a request body. 132 | 133 | **Possible Responses:** 134 | * **200 OK**: The user's tokens have been successfully revoked, and they are logged out. 135 | * **401 Unauthorized**: The request does not contain a valid authentication token, or the 136 | token has already expired/is invalid. 137 | """ 138 | try: 139 | service.ban_token(user_id=request.user.id) 140 | return base_response() 141 | except Exception as err: 142 | return base_response_with_error(err) 143 | -------------------------------------------------------------------------------- /apps/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/common/__init__.py -------------------------------------------------------------------------------- /apps/common/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /apps/common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CommonConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'apps.common' 8 | verbose_name = _("Common") 9 | -------------------------------------------------------------------------------- /apps/common/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/apps/common/migrations/__init__.py -------------------------------------------------------------------------------- /apps/common/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class BaseModel(models.Model): 5 | created_at = models.DateTimeField(auto_now_add=True) 6 | updated_at = models.DateTimeField(auto_now=True) 7 | 8 | class Meta: 9 | abstract = True 10 | -------------------------------------------------------------------------------- /apps/common/raising.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from pkg.logger.logger import get_logger 4 | 5 | 6 | logger = get_logger() 7 | 8 | 9 | def err_log(app: str, err: Exception, category: str, sub_category: str, meta: Dict) -> None: 10 | logger.error(app=app, msg=str(err), category=category, sub_category=sub_category, meta=meta) 11 | -------------------------------------------------------------------------------- /apps/common/validations.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | iranian_mobile_regex = r"^09\d{9}$" 5 | 6 | def validate_iranian_mobile(mobile: str) -> bool: 7 | if not re.match(iranian_mobile_regex, mobile): 8 | return False 9 | return True 10 | 11 | 12 | iranian_telephone_pattern = r"^(0[1-9]{2,3})([2-9][0-9]{7})$" 13 | 14 | def validate_iranian_telephone(telephone: str) -> bool: 15 | if not re.match(iranian_telephone_pattern, telephone): 16 | return False 17 | return True 18 | -------------------------------------------------------------------------------- /compose-development.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | postgres_syntax_data: 3 | 4 | services: 5 | redis: 6 | image: redis:8.0.3 7 | container_name: syntaxfa_redis 8 | hostname: ${REDIS_HOST:?error} 9 | restart: always 10 | command: redis-server --requirepass ${REDIS_PASSWORD:?error} --port ${REDIS_PORT:?error} 11 | environment: 12 | REDIS_PASSWORD: ${REDIS_PASSWORD:?error} 13 | REDIS_PORT: ${REDIS_PORT:?error} 14 | volumes: 15 | - type: bind 16 | source: ./redis.conf 17 | target: /usr/local/etc/redis.conf 18 | read_only: true 19 | # ./redis.conf:/usr/local/etc/redis.conf:ro 20 | ports: 21 | - ${REDIS_PORT:?error}:${REDIS_PORT:?error} 22 | 23 | syntax_postgres: 24 | image: postgres:17 25 | container_name: syntax_postgres 26 | hostname: ${POSTGRES_HOST} 27 | command: -p ${POSTGRES_PORT} 28 | restart: always 29 | environment: 30 | - POSTGRES_DB=${POSTGRES_DB_NAME:?error} 31 | - POSTGRES_USER=${POSTGRES_USERNAME:?error} 32 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?error} 33 | volumes: 34 | - postgres_syntax_data:/var/lib/postgresql/data 35 | ports: 36 | - ${POSTGRES_PORT:?error}:${POSTGRES_PORT:?error} 37 | healthcheck: 38 | test: [CMD-SHELL, "sh -c 'pg_isready -U ${POSTGRES_USERNAME} -d ${POSTGRES_DB_NAME} -p ${POSTGRES_PORT}'"] 39 | interval: 10s 40 | timeout: 5s 41 | retries: 5 42 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | name: syntax-compose 2 | 3 | volumes: 4 | syntax_postgres_data: 5 | 6 | networks: 7 | backend_network: 8 | name: syntax_compose_backend_network 9 | driver: bridge 10 | internal: true 11 | external: false 12 | labels: 13 | com.syntaxfa.description: "Backend network" 14 | com.syntaxfa.department: "IT/Backend" 15 | webserver_network: 16 | name: syntax_compose_webserver_network 17 | driver: bridge 18 | external: false 19 | internal: false 20 | labels: 21 | com.syntaxfa.description: "Backend webserver network" 22 | con.syntaxfa.department: "IT/Backend" 23 | 24 | services: 25 | redis_syntax: 26 | container_name: ${REDIS_HOST:?error} 27 | hostname: ${REDIS_HOST:?error} 28 | image: redis:8.0.3 29 | restart: always 30 | command: redis-server --requirepass ${REDIS_PASSWORD:?error} --port ${REDIS_PORT:?error} 31 | environment: 32 | REDIS_PASSWORD: ${REDIS_PASSWORD:?error} 33 | REDIS_PORT: ${REDIS_PORT:?error} 34 | volumes: 35 | - type: bind 36 | source: ./redis.conf 37 | target: /usr/local/etc/redis.conf 38 | read_only: true 39 | # ./redis.conf:/usr/local/etc/redis.conf:ro 40 | networks: 41 | - backend_network 42 | healthcheck: 43 | test: [ "CMD", "redis-cli", "-h", "localhost", "-p", "${REDIS_PORT:?error}", "-a", "${REDIS_PASSWORD:?error}", "PING" ] 44 | interval: 5s 45 | timeout: 5s 46 | retries: 10 47 | 48 | syntax_postgres: 49 | image: postgres:17 50 | container_name: syntax_postgres 51 | hostname: ${POSTGRES_HOST:?error} 52 | command: -p ${POSTGRES_PORT:?error} 53 | restart: always 54 | environment: 55 | - POSTGRES_DB=${POSTGRES_DB_NAME:?error} 56 | - POSTGRES_USER=${POSTGRES_USERNAME:?error} 57 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?error} 58 | volumes: 59 | - syntax_postgres_data:/var/lib/postgresql/data 60 | networks: 61 | - backend_network 62 | healthcheck: 63 | test: [CMD-SHELL, "sh -c 'pg_isready -U ${POSTGRES_USERNAME} -d ${POSTGRES_DB_NAME} -p ${POSTGRES_PORT}'"] 64 | interval: 10s 65 | timeout: 5s 66 | retries: 5 67 | 68 | syntax_celery_worker: 69 | build: 70 | context: . 71 | dockerfile: ./Dockerfile 72 | command: celery -A config worker -B --loglevel=info 73 | env_file: 74 | - .env 75 | depends_on: 76 | - syntax_app 77 | - redis_syntax 78 | networks: 79 | - backend_network 80 | volumes: 81 | - .:/code/ 82 | restart: always 83 | 84 | syntax_celery_beat: 85 | build: 86 | context: . 87 | dockerfile: ./Dockerfile 88 | command: 'celery -A config beat -l info -S django' 89 | volumes: 90 | - .:/code/ 91 | depends_on: 92 | - syntax_app 93 | - syntax_celery_worker 94 | networks: 95 | - backend_network 96 | restart: always 97 | 98 | syntax_app: 99 | build: 100 | context: . 101 | dockerfile: Dockerfile 102 | hostname: syntax_project 103 | container_name: syntaxfa_container 104 | env_file: 105 | - .env 106 | restart: always 107 | command: sh -c "python3 entrypoint.py" 108 | depends_on: 109 | redis_syntax: 110 | condition: service_healthy 111 | restart: false 112 | networks: 113 | - backend_network 114 | - webserver_network 115 | volumes: 116 | - type: bind 117 | source: . 118 | target: /code/ 119 | - type: bind 120 | source: ./media 121 | target: /code/media 122 | - type: bind 123 | source: ./static 124 | target: /code/static 125 | labels: 126 | com.syntaxfa.project: "syntaxfa" 127 | com.syntaxfa.description: "Syntaxfa backend application" 128 | com.syntaxfa.department: "IT/Backend" 129 | 130 | nginx_syntax: 131 | image: nginx:latest 132 | hostname: nginx_syntax 133 | container_name: nginx_syntax_container 134 | command: nginx -g 'daemon off;' 135 | restart: always 136 | depends_on: 137 | syntax_app: 138 | condition: service_started 139 | restart: false 140 | ports: 141 | - 80:80 142 | networks: 143 | - webserver_network 144 | volumes: 145 | - type: bind 146 | source: ./nginx.conf 147 | target: /etc/nginx/nginx.conf 148 | read_only: true 149 | - type: bind 150 | source: ./media 151 | target: /code/media 152 | read_only: true 153 | - type: bind 154 | source: ./static 155 | target: /code/static 156 | read_only: true 157 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | from config.celery_conf import celery_app 2 | -------------------------------------------------------------------------------- /config/asgi.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | """ 3 | ASGI config for config project. 4 | 5 | It exposes the ASGI callable as a module-level variable named ``application``. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ 9 | """ 10 | from django.core.asgi import get_asgi_application 11 | 12 | import environment 13 | 14 | 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /config/celery_conf.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | import os 3 | from datetime import timedelta 4 | 5 | from django.conf import settings 6 | from celery import Celery 7 | from dotenv import load_dotenv 8 | 9 | load_dotenv() 10 | 11 | 12 | match os.getenv("DJANGO_ENV"): 13 | case "production": 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') 15 | case _: 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') 17 | 18 | celery_app = Celery('A') 19 | celery_app.autodiscover_tasks() 20 | 21 | broker_url = getattr(settings, "CELERY_BROKER_URL") 22 | celery_app.conf.broker_url = broker_url 23 | 24 | celery_app.conf.result_backend = broker_url 25 | celery_app.conf.task_serializer = 'json' 26 | celery_app.conf.result_serializer = 'json' 27 | celery_app.conf.accept_content = ['json'] 28 | celery_app.conf.result_expires = timedelta(hours=12) 29 | celery_app.conf.task_always_eager = False 30 | celery_app.conf.worker_prefetch_multiplier = int(getattr(settings, "CELERY_PREFETCH_MULTIPLIER", 12)) 31 | -------------------------------------------------------------------------------- /config/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/config/settings/__init__.py -------------------------------------------------------------------------------- /config/settings/base.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wildcard-import, missing-module-docstring, duplicate-code, import-error 2 | import os 3 | from datetime import timedelta 4 | from pathlib import Path 5 | from dotenv import load_dotenv 6 | 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | load_dotenv() 10 | 11 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 12 | 13 | SECRET_KEY = os.getenv("SECURITY_KEY") 14 | 15 | # GENERAL 16 | # ------------------------------------------------------------------------------ 17 | LANGUAGE_CODE = 'en-us' 18 | TIME_ZONE = 'UTC' 19 | USE_I18N = True 20 | USE_TZ = True 21 | LOCALE_PATH = [BASE_DIR / "locale"] 22 | LANGUAGES = ( 23 | ('en', _('English')), 24 | ('fa', _('Persian')), 25 | ) 26 | 27 | # DATABASES 28 | # ------------------------------------------------------------------------------ 29 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 30 | 31 | # URLS 32 | # ------------------------------------------------------------------------------ 33 | ROOT_URLCONF = 'config.urls' 34 | WSGI_APPLICATION = 'config.wsgi.application' 35 | 36 | # APPS 37 | # ------------------------------------------------------------------------------ 38 | DJANGO_APPS = [ 39 | 'django.contrib.admin', 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.messages', 44 | 'django.contrib.staticfiles', 45 | 'django.contrib.sites', 46 | ] 47 | 48 | THIRD_PARTY_APPS = [ 49 | 'rest_framework', 50 | 'drf_spectacular', 51 | 'django_celery_beat', 52 | ] 53 | 54 | LOCAL_APPS = [ 55 | "apps.api.apps.ApiConfig", 56 | "apps.common.apps.CommonConfig", 57 | "apps.authentication.apps.AuthenticationConfig" 58 | ] 59 | 60 | INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS 61 | 62 | # STATIC 63 | # ------------------------------------------------------------------------------ 64 | STATIC_URL = "static/" 65 | STATIC_ROOT = BASE_DIR / "static" 66 | 67 | # MEDIA 68 | # ------------------------------------------------------------------------------ 69 | MEDIA_URL = "media/" 70 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 71 | 72 | # TEMPLATES 73 | # ------------------------------------------------------------------------------ 74 | TEMPLATES = [ 75 | { 76 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 77 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 78 | 'APP_DIRS': True, 79 | 'OPTIONS': { 80 | 'context_processors': [ 81 | 'django.template.context_processors.debug', 82 | 'django.template.context_processors.request', 83 | 'django.contrib.auth.context_processors.auth', 84 | 'django.contrib.messages.context_processors.messages', 85 | ], 86 | }, 87 | }, 88 | ] 89 | 90 | # PASSWORD VALIDATION 91 | # ------------------------------------------------------------------------------ 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 104 | }, 105 | ] 106 | 107 | # MIDDLEWARES 108 | # ------------------------------------------------------------------------------ 109 | BASE_MIDDLEWARE = [ 110 | 'django.middleware.security.SecurityMiddleware', 111 | 'django.contrib.sessions.middleware.SessionMiddleware', 112 | 'django.middleware.common.CommonMiddleware', 113 | 'django.middleware.csrf.CsrfViewMiddleware', 114 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 115 | 'django.contrib.messages.middleware.MessageMiddleware', 116 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 117 | ] 118 | 119 | 120 | def insert_middleware(middleware_list, middleware_to_add, position): 121 | middleware_list.insert(position, middleware_to_add) 122 | return middleware_list 123 | 124 | 125 | MIDDLEWARE = BASE_MIDDLEWARE.copy() 126 | 127 | # DRF 128 | # ------------------------------------------------------------------------------ 129 | REST_FRAMEWORK = { 130 | 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 131 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 132 | 'apps.authentication.authenticate.JWTAuthentication', 133 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 134 | ), 135 | } 136 | 137 | # SWAGGER 138 | # ------------------------------------------------------------------------------ 139 | SPECTACULAR_SETTINGS = { 140 | 'TITLE': 'SYNTAX-FA API', 141 | 'DESCRIPTION': 'syntax fa apis', 142 | 'VERSION': '1.0.0', 143 | 'SERVE_INCLUDE_SCHEMA': True, 144 | # OTHER SETTINGS 145 | 'PLUGINS': [ 146 | 'drf_spectacular.plugins.AuthPlugin', 147 | ], 148 | # 'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'], 149 | # Auth with session only in docs without effect to api 150 | 'SERVE_AUTHENTICATION': ["rest_framework.authentication.SessionAuthentication", 151 | "rest_framework_simplejwt.authentication.JWTAuthentication"], 152 | } 153 | 154 | # PROJECT ADMIN EMAIL 155 | # ------------------------------------------------------------------------------ 156 | EMAIL_BACKEND = os.getenv('EMAIL_BACKEND') 157 | EMAIL_HOST = os.getenv('EMAIL_HOST') 158 | EMAIL_PORT = os.getenv('EMAIL_PORT') 159 | EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') 160 | EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') 161 | EMAIL_VERIFICATION_URL_TIMEOUT = 60 * 3 # seconds 162 | EMAIL_USE_SSL = True 163 | 164 | # Accounts config 165 | # ------------------------------------------------------------------------------ 166 | # AUTH_USER_MODEL = "accounts.User" 167 | SITE_ID = 1 168 | 169 | # JWT 170 | # ------------------------------------------------------------------------------ 171 | JWT_ACCESS_TOKEN_LIFETIME_IN_SECONDS = int(os.getenv("JWT_ACCESS_TOKEN_LIFETIME_IN_SECONDS")) 172 | JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS")) 173 | 174 | SIMPLE_JWT = { 175 | "ACCESS_TOKEN_LIFETIME": timedelta(seconds=JWT_ACCESS_TOKEN_LIFETIME_IN_SECONDS), 176 | "REFRESH_TOKEN_LIFETIME": timedelta(days=JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS) 177 | } 178 | 179 | # LOGGER 180 | LOGGER_NAME = os.getenv("LOGGER_NAME") 181 | -------------------------------------------------------------------------------- /config/settings/development.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wildcard-import, missing-module-docstring, unused-wildcard-import, duplicate-code, import-error 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from .base import * 6 | 7 | load_dotenv() 8 | 9 | # GENERAL 10 | # ------------------------------------------------------------------------------ 11 | DEBUG = True 12 | 13 | # DATABASES 14 | # ------------------------------------------------------------------------------ 15 | DB_NAME = os.getenv('POSTGRES_DB_NAME') 16 | DB_USER = os.getenv('POSTGRES_USERNAME') 17 | DB_PASS = os.getenv('POSTGRES_PASSWORD') 18 | DB_HOST = os.getenv('POSTGRES_HOST_DEBUG') 19 | DB_PORT = os.getenv('POSTGRES_PORT') 20 | POSTGRES_CONN_MAX_AGE = int(os.getenv('POSTGRES_CONN_MAX_AGE')) 21 | 22 | DATABASES = { 23 | 'default': { 24 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 25 | 'NAME': DB_NAME, 26 | 'USER': DB_USER, 27 | 'PASSWORD': DB_PASS, 28 | 'HOST': DB_HOST, 29 | 'PORT': DB_PORT, 30 | 'CONN_MAX_AGE': POSTGRES_CONN_MAX_AGE, 31 | } 32 | } 33 | 34 | # DATABASES["default"]["ATOMIC_REQUESTS"] = True 35 | 36 | # APPS 37 | # ------------------------------------------------------------------------------ 38 | APPS = [ 39 | "debug_toolbar" 40 | ] 41 | INSTALLED_APPS += APPS 42 | 43 | # HOSTS 44 | # ------------------------------------------------------------------------------ 45 | ALLOWED_HOSTS = ["*"] 46 | 47 | # MIDDLEWARE 48 | # ------------------------------------------------------------------------------ 49 | insert_middleware(MIDDLEWARE, "debug_toolbar.middleware.DebugToolbarMiddleware", 2) 50 | 51 | # DJANGO DEBUG 52 | # ------------------------------------------------------------------------------ 53 | INTERNAL_IPS = [ 54 | "127.0.0.1", 55 | ] 56 | 57 | # CACHE 58 | # ------------------------------------------------------------------------------ 59 | CACHES = { 60 | "default": { 61 | "BACKEND": "django_redis.cache.RedisCache", 62 | "LOCATION": f"redis://{os.getenv('REDIS_HOST_DEBUG')}:{os.getenv('REDIS_PORT')}/1", 63 | 'OPTIONS': { 64 | 'PASSWORD': os.getenv('REDIS_PASSWORD'), 65 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 66 | } 67 | } 68 | } 69 | 70 | SESSION_ENGINE = 'django.contrib.sessions.backends.cache' 71 | 72 | # CELERY 73 | CELERY_BROKER_URL = (f"redis://:{os.getenv('REDIS_PASSWORD')}@{os.getenv('REDIS_HOST_DEBUG')}" 74 | f":{os.getenv('REDIS_PORT')}/0") 75 | CELERY_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_PREFETCH_MULTIPLIER")) 76 | -------------------------------------------------------------------------------- /config/settings/production.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wildcard-import, missing-module-docstring, unused-wildcard-import, duplicate-code, import-error 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from corsheaders.defaults import default_headers, default_methods 6 | from config.settings.base import * 7 | 8 | load_dotenv() 9 | 10 | # GENERAL 11 | # ------------------------------------------------------------------------------ 12 | DEBUG = False 13 | 14 | # APPS 15 | # ------------------------------------------------------------------------------ 16 | APPS = [ 17 | ] 18 | INSTALLED_APPS += APPS 19 | 20 | # DATABASES 21 | # ------------------------------------------------------------------------------ 22 | DB_NAME = os.getenv('POSTGRES_DB_NAME') 23 | DB_USER = os.getenv('POSTGRES_USERNAME') 24 | DB_PASS = os.getenv('POSTGRES_PASSWORD') 25 | DB_HOST = os.getenv('POSTGRES_HOST') 26 | DB_PORT = os.getenv('POSTGRES_PORT') 27 | POSTGRES_CONN_MAX_AGE = int(os.getenv('POSTGRES_CONN_MAX_AGE')) 28 | 29 | DATABASES = { 30 | 'default': { 31 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 32 | 'NAME': DB_NAME, 33 | 'USER': DB_USER, 34 | 'PASSWORD': DB_PASS, 35 | 'HOST': DB_HOST, 36 | 'PORT': DB_PORT, 37 | 'CONN_MAX_AGE': POSTGRES_CONN_MAX_AGE, 38 | } 39 | } 40 | 41 | # DATABASES["default"]["ATOMIC_REQUESTS"] = True 42 | 43 | # HOSTS 44 | # ------------------------------------------------------------------------------ 45 | ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS").split(",") 46 | CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS] + [f"http://{host}" for host in ALLOWED_HOSTS] 47 | 48 | # CORS 49 | # ------------------------------------------------------------------------------ 50 | CORS_ALLOW_METHODS = ( 51 | *default_methods, 52 | ) 53 | 54 | CORS_ALLOW_HEADERS = ( 55 | *default_headers, 56 | # "my-custom-header", 57 | ) 58 | 59 | # TODO - change it when production ready!!! 60 | CORS_ORIGIN_ALLOW_ALL = True 61 | 62 | # MIDDLEWARE 63 | # ------------------------------------------------------------------------------ 64 | insert_middleware(MIDDLEWARE, "corsheaders.middleware.CorsMiddleware", 2) 65 | 66 | # CACHE 67 | # ------------------------------------------------------------------------------ 68 | CACHES = { 69 | "default": { 70 | "BACKEND": "django_redis.cache.RedisCache", 71 | "LOCATION": f"redis://{os.getenv('REDIS_HOST')}:{os.getenv('REDIS_PORT')}/1", 72 | 'OPTIONS': { 73 | 'PASSWORD': os.getenv('REDIS_PASSWORD'), 74 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 75 | } 76 | } 77 | } 78 | 79 | SESSION_ENGINE = 'django.contrib.sessions.backends.cache' 80 | 81 | # CELERY 82 | CELERY_BROKER_URL = f"redis://:{os.getenv('REDIS_PASSWORD')}@{os.getenv('REDIS_HOST')}:{os.getenv('REDIS_PORT')}/0" 83 | CELERY_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_PREFETCH_MULTIPLIER")) 84 | -------------------------------------------------------------------------------- /config/settings/test.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wildcard-import, missing-module-docstring, unused-wildcard-import, duplicate-code, import-error 2 | from .base import * 3 | 4 | # GENERAL 5 | # ------------------------------------------------------------------------------ 6 | DEBUG = True 7 | 8 | # DATABASES 9 | # ------------------------------------------------------------------------------ 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': BASE_DIR / 'db.sqlite3', 14 | } 15 | } 16 | 17 | # DATABASES["default"]["ATOMIC_REQUESTS"] = True 18 | 19 | # APPS 20 | # ------------------------------------------------------------------------------ 21 | APPS = [ 22 | ] 23 | INSTALLED_APPS += APPS 24 | 25 | # HOSTS 26 | # ------------------------------------------------------------------------------ 27 | ALLOWED_HOSTS = ["*"] 28 | 29 | # MIDDLEWARE 30 | # ------------------------------------------------------------------------------ 31 | 32 | # CACHE 33 | # ------------------------------------------------------------------------------ 34 | CACHES = { 35 | "default": { 36 | "BACKEND": "django_redis.cache.RedisCache", 37 | "LOCATION": f"redis://{os.getenv('REDIS_HOST_DEBUG')}:{os.getenv('REDIS_PORT')}/1", 38 | 'OPTIONS': { 39 | 'PASSWORD': os.getenv('REDIS_PASSWORD'), 40 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 41 | } 42 | } 43 | } 44 | 45 | SESSION_ENGINE = 'django.contrib.sessions.backends.cache' 46 | 47 | # CELERY 48 | CELERY_BROKER_URL = (f"redis://:{os.getenv('REDIS_PASSWORD')}@{os.getenv('REDIS_HOST_DEBUG')}" 49 | f":{os.getenv('REDIS_PORT')}/0") 50 | CELERY_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_PREFETCH_MULTIPLIER")) 51 | -------------------------------------------------------------------------------- /config/urls.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=import-error 2 | """ URL configuration """ 3 | from django.contrib import admin 4 | from django.urls import path, include 5 | from django.conf import settings 6 | from django.conf.urls.static import static 7 | 8 | urlpatterns = [ 9 | path('admin/', admin.site.urls), 10 | path("api/", include("apps.api.urls")), 11 | ] 12 | 13 | if settings.DEBUG: 14 | from debug_toolbar.toolbar import debug_toolbar_urls 15 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 16 | urlpatterns += debug_toolbar_urls() 17 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | """ 3 | WSGI config for config project. 4 | 5 | It exposes the WSGI callable as a module-level variable named ``application``. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ 9 | """ 10 | from django.core.wsgi import get_wsgi_application 11 | 12 | import environment 13 | 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /docs/commit-conventions.md: -------------------------------------------------------------------------------- 1 | ## Available types: 2 | - feat: A new feature 3 | - fix: A bug fix 4 | - docs: Documentation only changes 5 | - style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 6 | - refactor: A code change that neither fixes a bug nor adds a feature 7 | - perf: A code change that improves performance 8 | - test: Adding missing tests or correcting existing tests 9 | - build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 10 | - ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) 11 | - chore: Other changes that don't modify src or test files 12 | - revert: Reverts a previous commit 13 | -------------------------------------------------------------------------------- /docs/pre-commit.md: -------------------------------------------------------------------------------- 1 | ### Pre commit 2 | 3 | ## for install dependencies: 4 | ```shell 5 | pre-commit clean 6 | && 7 | pre-commit install 8 | && 9 | pre-commit install --hook-type commit-msg 10 | ``` 11 | 12 | ## for applying new added rule: 13 | ```shell 14 | pre-commit clean && 15 | pre-commit install Or pre-commit install --hook-type commit-msg 16 | ``` 17 | 18 | ## running pre-commit for test 19 | ```shell 20 | pre-commit run --all-files 21 | ``` 22 | 23 | ## testing gitlint 24 | ```shell 25 | echo "bad commit message" > test_commit_msg.txt 26 | && 27 | gitlint --msg-filename test_commit_msg.txt 28 | ``` 29 | 30 | ## Or: 31 | ```shell 32 | pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/pylint.md: -------------------------------------------------------------------------------- 1 | ### Pylint doc: 2 | 3 | * Structure: 4 | * my_project: 5 | * module1/ 6 | * __init__.py 7 | * file1.py 8 | * module2/ 9 | * __init__.py 10 | * file2.py 11 | * Usage for a single script: ```pylint path/to/my_script.py``` 12 | * Usage for entire of target project: ```pylint my_project/``` 13 | 14 | * More information in : https://www.linode.com/docs/guides/install-and-use-pylint-for-python-3/ 15 | 16 | ### Black doc: 17 | 18 | * Usage for a single script: ```black path/to/my_script.py``` 19 | * Usage for entire of target project: ```black my_project/``` 20 | * Check which python file(s) can be formatted in the target folder : ```black --check traget_folder/``` 21 | * Stop emitting all non-critical output(Error messages will still be emitted): ```black my_project/ -q``` 22 | * More information 23 | in: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-via-a-file 24 | 25 | ### How Ignore a file? 26 | comment this in target file: 27 | 28 | ```python 29 | # pylint: skip-file 30 | ``` 31 | 32 | ### Generate pyling configuration file: 33 | ```shell 34 | pylint --generate-rcfile > .pylintrc 35 | ``` -------------------------------------------------------------------------------- /docs/rich_error.md: -------------------------------------------------------------------------------- 1 | # Rich Error 2 | 3 | Rich Error is an error management pattern in programming that allows you to store detailed information about errors and the various layers in which these errors occurred before reaching you. Based on the collected information, you can easily display appropriate error messages to users or other systems. 4 | 5 | Unlike standard errors, which typically include just a message or error code, Rich Error can include additional information such as metadata, the operation that caused the error, lower-level errors, and any other relevant details. 6 | 7 | ## Why Do We Call It Rich Error? 8 | 9 | 1. **More Information**: 10 | Rich Error can include the error message, error code, the name of the operation that caused the error, and any other type of information. This information can include metadata that helps in better understanding the issue. 11 | 12 | 2. **Nested Structure**: 13 | Rich Error allows you to manage nested errors. This means that if an error is caused by another error, you can trace back to the root cause, ensuring that no error is overlooked. 14 | 15 | 3. **Simplifies Your Work**: 16 | As demonstrated in the examples, Rich Error simplifies your tasks and makes your code more organized and cohesive. 17 | 18 | If you’re still uncertain about using Rich Error, here are some additional benefits: 19 | 20 | 1. **Better Problem Diagnosis**: 21 | With rich information about errors, development teams can identify and resolve issues faster and more accurately. 22 | 23 | 2. **Enhanced User Experience**: 24 | When errors are presented clearly with sufficient information, the user experience improves significantly. 25 | 26 | 3. **Faster Development**: 27 | Using Rich Error can accelerate your development process, based on my own experience. 28 | 29 | 4. **Compatibility with Other Systems**: 30 | The structured and sufficient information provided by Rich Error can be easily transferred to other systems. For example, you can utilize Rich Error information in your logging systems. 31 | 32 | ## How to Write a Rich Error? 33 | 34 | One of the best ways to address such questions is to see how more experienced individuals have implemented this and what recommendations they provide. 35 | 36 | The Microsoft documentation, particularly in the ErrorDetail section of the API guidelines, offers valuable insights. A Rich Error can include several key properties that help you store comprehensive information about errors. Below is a concise explanation of each property. 37 | https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md 38 | 39 | 1. **operation** 40 | This indicates the operation in which the error occurred. For example, it could be the name of the method or function that led to the error. 41 | - **Best Practice**: 42 | - Use descriptive naming for the operation to make it easily identifiable. 43 | - This property can help you quickly identify issues in logs and reports. 44 | For example, if the function `get_user_by_id` is in the `user` package and the `crud` file, and the error occurs within this package, the operation value could be: 45 | ```python 46 | RichError(operation="user.crud.get_user_by_id") 47 | ``` 48 | 49 | 2. **code** 50 | This property represents the error code, which is usually a number or a specific identifier. This code specifically references the type of error and can be used for categorizing errors. 51 | - **Best Practice**: 52 | - Based on my experience, use a dictionary to manage error codes easily. 53 | - Codes should be unique and meaningful for easy identification and management. 54 | 55 | **Why Should Error Codes Be Unique?** 56 | This approach can help in error management. The idea is that each type of error should have a unique code that is easily identifiable and categorizable. 57 | 58 | **Benefits of Using Unique Error Codes**: 59 | 1. **Quick Error Identification**: 60 | Each error code points to a specific type of issue, helping us quickly understand what kind of error occurred. 61 | 2. **Error Categorization**: 62 | With unique codes, you can easily categorize errors based on type or resource. 63 | 3. **Better Guidance for Users**: 64 | Providing error codes to users or support teams allows them to easily refer to documentation or resources related to that error. 65 | 4. **Reduced Overlap**: 66 | Having specific codes for each type of error reduces the chance of overlap between errors. 67 | 68 | **Example of Error Codes**: 69 | To implement these codes, you can use a dictionary or Enum. I prefer using a dictionary: 70 | ```python 71 | USER_NOT_FOUND = 40401 72 | INVALID_INPUT = 40001 73 | INTERNAL_SERVER_ERROR = 50001 74 | ACCESS_DENIED = 40301 75 | 76 | ERROR_TRANSLATION = { 77 | USER_NOT_FOUND: "User does not exist", 78 | INVALID_INPUT: "Invalid input", 79 | INTERNAL_SERVER_ERROR: "Internal server error", 80 | ACCESS_DENIED: "Access denied" 81 | } 82 | ``` 83 | Using a dictionary, we can easily provide text descriptions for each error or even utilize i18n (I prefer that clients display the appropriate text in their desired language based on the code). 84 | 85 | 3. **message** 86 | A message that describes what type of error occurred. This message typically provides users or developers with more information about the nature of the error. 87 | - **Best Practice**: 88 | - Use clear and concise messages that are easy to understand. 89 | - Messages should differ based on the type of error and user access level (e.g., for end users, messages should be simpler without rich details). 90 | 91 | 4. **error** 92 | This property may refer to the underlying error that led to the Rich Error. It could be an exception that provides more details about the issue. 93 | - **Best Practice**: 94 | - Use this property to store nested errors, meaning if one error is caused by another, store it here. 95 | - Ensure that information related to the original error is stored completely and clearly. 96 | 97 | 5. **meta_data** 98 | This property can include any additional information that may be useful for better understanding the issue. For example, it can contain information about the system state, user inputs, etc. 99 | - **Best Practice**: 100 | - Use a dictionary to store metadata so that you can easily manage different types of data. 101 | - This information should be collected and analyzed based on the specific needs and context of the error. 102 | 103 | ## Now You're Familiar with Rich Error! 104 | 105 | At this point, you have a solid understanding of Rich Error. Next, I will explain the `rich-error` package and how to use Rich Error effectively and efficiently. 106 | 107 | The `rich-error` package supports all the features we discussed. Here are some key points to keep in mind when using the `rich-error` package: 108 | 109 | 1. **Align Your Codes with HTTP Codes**: 110 | Try to make your error codes correspond to HTTP status codes. This way, your codes can easily be converted into HTTP error responses. For example, use codes like: 111 | ```python 112 | OK = 200_00 113 | USER_NOT_FOUND = 404_01 114 | USER_CONFLICT = 409_01 115 | ``` 116 | The first three digits of the codes should match the HTTP codes, while the subsequent digits can be whatever you prefer. Typically, codes range from five to six digits. Depending on the size of your project, you might have more than 99 instances of a specific code like 404, but I believe two additional digits are usually sufficient. 117 | 118 | 2. **Include Useful Information**: 119 | Make sure to include as much useful information as possible within the Rich Error to simplify debugging. 120 | 121 | 3. **Access Error Information Easily**: 122 | You can easily access the information inside the error using the `get_error_info` function. 123 | 124 | By following these guidelines, you can effectively implement and utilize Rich Error in your applications. 125 | 126 | 127 | ## Example Using Rich Error 128 | 129 | Let's consider an example where our application handles requests using a structure where a handler processes requests via a service, and if necessary, the service interacts with a database using a repository. 130 | 131 | ### Types of Errors That May Occur 132 | 133 | Several types of errors may occur during this process. An error could happen in the repository and be returned to the service, which may also encounter its own set of errors. Ultimately, the handler should provide an appropriate response to the user. 134 | 135 | Using Rich Error allows us to maintain a good log of errors as well as present a suitable response to the user. Here's how the code structure looks: 136 | 137 | ### `codes.py` 138 | ```python 139 | OK = 200_00 140 | USER_NOT_FOUND = 404_01 141 | USER_CONFLICT = 409_01 142 | IP_BLOCKED = 403_01 143 | TOO_MANY_REQUEST = 429_01 144 | INTERNAL_SERVER_ERROR = 500_01 145 | 146 | ERROR_TRANSLATION = { 147 | OK: "ok", 148 | USER_NOT_FOUND: "user does not exist", # We can use i18n for error translations, but I prefer not to. 149 | INTERNAL_SERVER_ERROR: "unexpected error", 150 | USER_CONFLICT: "this user already exists", 151 | IP_BLOCKED: "IP blocked", 152 | TOO_MANY_REQUEST: "too many requests", 153 | } 154 | ``` 155 | 156 | ### `api.py` 157 | ```python 158 | from typing import Dict 159 | from rich_error.utils.http.response import BaseResponse 160 | from rich_error.utils.http.response import base_response as base_res 161 | from rich_error.utils.http.response import base_response_with_error as base_res_error 162 | from examples.with_rich_error.codes import OK, ERROR_TRANSLATION 163 | 164 | def base_response(result: Dict | None, status_code: int = 200) -> BaseResponse: 165 | return base_res(status_code=status_code, code=OK, result=result) 166 | 167 | def base_response_with_error(error: Exception): 168 | return base_res_error(error=error, error_translation=ERROR_TRANSLATION, code_k=100) 169 | ``` 170 | 171 | ### `repository.py` 172 | ```python 173 | from examples.with_rich_error.codes import USER_NOT_FOUND 174 | from rich_error.error import RichError 175 | 176 | class UserRepository: 177 | Op = "example.web.repository.UserRepository." 178 | 179 | def get_user_by_id(self, user_id: int): 180 | op = self.Op + "get_user_by_id" 181 | raise RichError(op).set_msg("User with this ID %d not found" % user_id).set_code(USER_NOT_FOUND) 182 | 183 | def get_user_repository(): 184 | return UserRepository() 185 | ``` 186 | 187 | ### `service.py` 188 | ```python 189 | import random 190 | from abc import ABC, abstractmethod 191 | from logging import error 192 | from examples.with_rich_error import codes 193 | from examples.with_rich_error.repository import get_user_repository 194 | from rich_error.error import RichError, get_error_info 195 | 196 | class Repository(ABC): 197 | @abstractmethod 198 | def get_user_by_id(self, user_id): 199 | pass 200 | 201 | class UserService: 202 | Op = "examples.web.service.UserService." 203 | 204 | def __init__(self, repo: Repository): 205 | self.repo = repo 206 | 207 | def get_user_by_id(self, user_id: int): 208 | op = self.Op + "get_user_by_id" 209 | meta = {"user_id": user_id} 210 | 211 | try: 212 | match random.randint(1, 5): 213 | case 1: 214 | raise RichError(op).set_code(codes.USER_CONFLICT) 215 | case 2: 216 | raise RichError(op).set_code(codes.TOO_MANY_REQUEST) 217 | case 3: 218 | raise RichError(op).set_code(codes.IP_BLOCKED) 219 | case (4, 5): 220 | return self.repo.get_user_by_id(user_id=user_id) 221 | 222 | return self.repo.get_user_by_id(user_id=user_id) 223 | except Exception as err: 224 | """ 225 | Since we have used Rich Error throughout the system, we know that the lower layer has handled it, 226 | and we just need to propagate the error. 227 | """ 228 | # log error self.logger.error(...) 229 | rich_err = RichError(op).set_error(err).set_meta(meta) 230 | error(msg=get_error_info(error=rich_err)) 231 | raise rich_err 232 | 233 | def get_user_service(): 234 | return UserService(repo=get_user_repository()) 235 | ``` 236 | 237 | ### `handler.py` 238 | ```python 239 | from examples.with_rich_error.service import get_user_service 240 | from examples.with_rich_error.api import base_response_with_error, base_response 241 | 242 | service = get_user_service() 243 | 244 | 245 | def get_user_handler(user_id: int): 246 | try: 247 | return base_response(result=service.get_user_by_id(user_id=user_id)) 248 | except Exception as err: 249 | return base_response_with_error(error=err) 250 | 251 | 252 | if __name__ == "__main__": 253 | print(get_user_handler(user_id=1)) 254 | ``` 255 | 256 | ### error info: 257 | ```shell 258 | ERROR:root:[{'Operation': 'examples.web.service.UserService.get_user_by_id', 'Code': 40401, 'Message': 'user with this id 1 not found', 'Meta': {'user_id': 1}}, {'Operation': 'example.web.repository.UserRepository.get_user_by_id', 'Code': 40401, 'Message': 'user with this id 1 not found', 'Meta': {}}] 259 | ``` 260 | 261 | By using Rich Error, we can log errors effectively and provide meaningful responses to users, enhancing both debugging and user experience. 262 | 263 | 264 | ## The Drawback of Not Using Rich Error 265 | 266 | If we didn't use Rich Error, we would have to handle different errors using alternative solutions, such as defining specific exceptions for each error type. For example, we might write: 267 | 268 | ```python 269 | class UserNotFoundErr(Exception): 270 | pass 271 | 272 | class IpBlockedErr(Exception): 273 | pass 274 | 275 | class TooManyRequestErr(Exception): 276 | pass 277 | 278 | class UserConflictErr(Exception): 279 | pass 280 | ``` 281 | 282 | In the handler, we would also need to define these exceptions: 283 | 284 | ```python 285 | from examples.without_rich_error.service import get_user_service 286 | from examples.without_rich_error.exception import UserNotFoundErr, UserConflictErr, TooManyRequestErr, IpBlockedErr 287 | 288 | service = get_user_service() 289 | 290 | def get_user_handler(user_id: int): 291 | try: 292 | print(service.get_user_by_id(user_id=user_id)) 293 | except UserNotFoundErr as err: 294 | print("User not found, code is 404:", err) 295 | except UserConflictErr as err: 296 | print("User conflict, code is 409:", err) 297 | except TooManyRequestErr as err: 298 | print("Too many requests, code is 429:", err) 299 | except IpBlockedErr as err: 300 | print("IP blocked, code is 403:", err) 301 | 302 | if __name__ == "__main__": 303 | print(get_user_handler(user_id=1)) 304 | ``` 305 | 306 | ### Challenges with This Approach 307 | 308 | 1. **Increased Duplication**: As the number of exceptions grows, managing them becomes increasingly complex and leads to code duplication. 309 | 310 | 2. **Reduced Observability**: The system's ability to provide insights into errors diminishes. In contrast, using Rich Error allows us to capture all errors occurring at various layers, maintaining a comprehensive view of the error landscape. 311 | 312 | By leveraging Rich Error, we can simplify error management, reduce duplication, and enhance the observability of our error handling mechanism. 313 | -------------------------------------------------------------------------------- /entrypoint.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gunicorn should only need 4-12 worker processes to handle hundreds or thousands of requests per second. 3 | Gunicorn relies on the operating system to provide all of the load balancing when handling requests. 4 | Generally we recommend (2 x $num_cores) + 1 as the number of workers to start off with. 5 | 6 | docs: https://docs.gunicorn.org/en/latest/design.html 7 | """ 8 | import os 9 | 10 | cpu_count = os.cpu_count() 11 | 12 | print(f"cpu count is {cpu_count}") 13 | 14 | if cpu_count == 1: 15 | WORKERS = 4 16 | else: 17 | WORKERS = (cpu_count * 2) + 1 18 | 19 | 20 | # running django 21 | os.system("python3 manage.py migrate --no-input") 22 | os.system("python3 manage.py collectstatic --no-input") 23 | os.system( 24 | f"gunicorn -k gevent --workers {WORKERS} " 25 | f"--worker-tmp-dir /dev/shm --chdir config config.wsgi:application " 26 | f"-b 0.0.0.0:{os.getenv('DJANGO_PORT')}") 27 | -------------------------------------------------------------------------------- /environment.py: -------------------------------------------------------------------------------- 1 | # environment.py 2 | import os 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | 8 | match os.getenv("DJANGO_ENV"): 9 | case "production": 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') 11 | case "test": 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.test') 13 | case _: 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') 15 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/example/__init__.py -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # pylint: skip-file 3 | """Django's command-line utility for administrative tasks.""" 4 | import sys 5 | 6 | import environment 7 | 8 | 9 | def main(): 10 | """Run administrative tasks.""" 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | error_log /var/log/nginx/error_log warn; 4 | pid /var/run/nginx.pid; 5 | 6 | 7 | events{ 8 | worker_connections 1024; 9 | 10 | } 11 | 12 | http { 13 | include /etc/nginx/mime.types; 14 | default_type application/octet-stream; 15 | access_log /var/log/nginx/access.log; 16 | 17 | upstream syntax_app { 18 | server syntax_app:8000; 19 | } 20 | 21 | server { 22 | listen 80; 23 | server_name api.syntax.com; 24 | charset utf-8; 25 | 26 | location /static/ { 27 | alias /code/static/; 28 | } 29 | 30 | location /media/ { 31 | alias /code/media/; 32 | } 33 | 34 | location / { 35 | client_max_body_size 30000M; 36 | client_body_buffer_size 200000K; 37 | proxy_redirect off; 38 | proxy_set_header Host $host; 39 | proxy_set_header X-Real-IP $remote_addr; 40 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 41 | proxy_set_header X-Forwarded-Host $server_name; 42 | proxy_pass http://syntax_app; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/__init__.py -------------------------------------------------------------------------------- /pkg/client/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg.client.client import get_client_info, get_ip_address, ClientInfo 2 | 3 | __all__ = ("get_client_info", "get_ip_address", "ClientInfo") 4 | -------------------------------------------------------------------------------- /pkg/client/client.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from dataclasses import dataclass 3 | 4 | from django.http import HttpRequest 5 | 6 | 7 | @dataclass 8 | class ClientInfo: 9 | ip_address: str 10 | device_name: str 11 | 12 | 13 | def get_ip_address(request: HttpRequest) -> str: 14 | ip_address = request.META.get('HTTP_X_FORWARDED_FOR', None) 15 | if ip_address: 16 | ip_address = ip_address.split(",")[0] 17 | else: 18 | ip_address = request.META.get("REMOTE_ADDR", '').split(",")[0] 19 | 20 | possibles = (ip_address.lstrip("[").split("]")[0], ip_address.split(":")[0]) 21 | 22 | for addr in possibles: 23 | try: 24 | return str(ipaddress.ip_address(addr)) 25 | except Exception as _: 26 | pass 27 | 28 | return ip_address 29 | 30 | 31 | def get_client_info(request: HttpRequest) -> ClientInfo: 32 | return ClientInfo( 33 | ip_address=get_ip_address(request=request), 34 | device_name=request.META.get('HTTP_USER_AGENT', '') 35 | ) 36 | -------------------------------------------------------------------------------- /pkg/client/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase, RequestFactory 2 | 3 | from . import client 4 | 5 | 6 | class TestClient(SimpleTestCase): 7 | 8 | def test_request_ip_address_remote(self): 9 | request = RequestFactory().get(path="/") 10 | request.META["REMOTE_ADDR"] = "127.0.0.1" 11 | client_info = client.get_client_info(request=request) 12 | self.assertEqual(client_info.ip_address, "127.0.0.1") 13 | 14 | def test_request_ip_address_remote_list(self): 15 | request = RequestFactory().get(path="/") 16 | request.META["REMOTE_ADDR"] = "127.0.0.1, 128.1.0.8" 17 | client_info = client.get_client_info(request=request) 18 | self.assertEqual(client_info.ip_address, "127.0.0.1") 19 | 20 | def test_request_ip_address_remote_with_port(self): 21 | request = RequestFactory().get(path="/") 22 | request.META["REMOTE_ADDR"] = "127.0.0.1:1234" 23 | client_info = client.get_client_info(request=request) 24 | self.assertEqual(client_info.ip_address, "127.0.0.1") 25 | 26 | def test_request_ip_address_remote_with_port_list(self): 27 | request = RequestFactory().get(path="/") 28 | request.META["REMOTE_ADDR"] = "127.0.0.1:1234, 128.0.0.9:1234" 29 | client_info = client.get_client_info(request=request) 30 | self.assertEqual(client_info.ip_address, "127.0.0.1") 31 | 32 | def test_request_ip_address_v6(self): 33 | request = RequestFactory().get(path="/") 34 | request.META["REMOTE_ADDR"] = "2001:0db8:85a3:0000:0000:8a2e:0370:7334" 35 | client_info = client.get_client_info(request=request) 36 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 37 | 38 | def test_request_ip_address_v6_list(self): 39 | request = RequestFactory().get(path="/") 40 | request.META["REMOTE_ADDR"] = ("2001:0db8:85a3:0000:0000:8a2e:0370:7334," 41 | " 3001:0db8:85a3:0000:0000:8a2e:0370:7334") 42 | client_info = client.get_client_info(request=request) 43 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 44 | 45 | def test_request_ip_address_v6_loopback(self): 46 | request = RequestFactory().get(path="/") 47 | request.META["REMOTE_ADDR"] = "::1" 48 | client_info = client.get_client_info(request=request) 49 | self.assertEqual(client_info.ip_address, "::1") 50 | 51 | def test_request_ip_address_v6_with_port(self): 52 | request = RequestFactory().get(path="/") 53 | request.META["REMOTE_ADDR"] = "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234" 54 | client_info = client.get_client_info(request=request) 55 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 56 | 57 | def test_request_ip_address_v6_with_port_list(self): 58 | request = RequestFactory().get(path="/") 59 | request.META["REMOTE_ADDR"] = ("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234," 60 | " [3001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234") 61 | client_info = client.get_client_info(request=request) 62 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 63 | 64 | def test_request_ip_address_x_forwarded(self): 65 | request = RequestFactory().get(path="/") 66 | request.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.1" 67 | client_info = client.get_client_info(request=request) 68 | self.assertEqual(client_info.ip_address, "127.0.0.1") 69 | 70 | def test_request_ip_address_x_forwarded_list(self): 71 | request = RequestFactory().get(path="/") 72 | request.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.1, 128.1.0.8" 73 | client_info = client.get_client_info(request=request) 74 | self.assertEqual(client_info.ip_address, "127.0.0.1") 75 | 76 | def test_request_ip_address_x_forwarded_with_port(self): 77 | request = RequestFactory().get(path="/") 78 | request.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.1:1234" 79 | client_info = client.get_client_info(request=request) 80 | self.assertEqual(client_info.ip_address, "127.0.0.1") 81 | 82 | def test_request_ip_address_x_forwarded_with_port_list(self): 83 | request = RequestFactory().get(path="/") 84 | request.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.1:1234, 128.0.0.9:1234" 85 | client_info = client.get_client_info(request=request) 86 | self.assertEqual(client_info.ip_address, "127.0.0.1") 87 | 88 | def test_request_ip_address_v6_x_forwarded(self): 89 | request = RequestFactory().get(path="/") 90 | request.META["HTTP_X_FORWARDED_FOR"] = "2001:0db8:85a3:0000:0000:8a2e:0370:7334" 91 | client_info = client.get_client_info(request=request) 92 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 93 | 94 | def test_request_ip_address_v6_x_forwarded_list(self): 95 | request = RequestFactory().get(path="/") 96 | request.META["HTTP_X_FORWARDED_FOR"] = ("2001:0db8:85a3:0000:0000:8a2e:0370:7334," 97 | " 3001:0db8:85a3:0000:0000:8a2e:0370:7334") 98 | client_info = client.get_client_info(request=request) 99 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 100 | 101 | def test_request_ip_address_v6_loopback_x_forwarded(self): 102 | request = RequestFactory().get(path="/") 103 | request.META["HTTP_X_FORWARDED_FOR"] = "::1" 104 | client_info = client.get_client_info(request=request) 105 | self.assertEqual(client_info.ip_address, "::1") 106 | 107 | def test_request_ip_address_v6_with_port_x_forwarded(self): 108 | request = RequestFactory().get(path="/") 109 | request.META["HTTP_X_FORWARDED_FOR"] = "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234" 110 | client_info = client.get_client_info(request=request) 111 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 112 | 113 | def test_request_ip_address_v6_with_port_x_forwarded_list(self): 114 | request = RequestFactory().get(path="/") 115 | request.META["HTTP_X_FORWARDED_FOR"] = ("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234," 116 | " [3001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234") 117 | client_info = client.get_client_info(request=request) 118 | self.assertEqual(client_info.ip_address, "2001:db8:85a3::8a2e:370:7334") 119 | 120 | def test_device_name(self): 121 | request = RequestFactory().get(path="/") 122 | request.META["HTTP_X_FORWARDED_FOR"] = ("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234," 123 | " [3001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234") 124 | request.META["HTTP_USER_AGENT"] = "test-device" 125 | client_info = client.get_client_info(request=request) 126 | self.assertEqual(client_info.device_name, "test-device") 127 | 128 | def test_device_name_empty(self): 129 | request = RequestFactory().get(path="/") 130 | client_info = client.get_client_info(request=request) 131 | self.assertEqual(client_info.device_name, "") 132 | -------------------------------------------------------------------------------- /pkg/email/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/email/__init__.py -------------------------------------------------------------------------------- /pkg/email/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from typing import List, Optional 4 | 5 | 6 | @dataclass 7 | class EmailStruct: 8 | subject: str 9 | body: str 10 | receivers: List[str] 11 | sender: str 12 | link: Optional[str] = None 13 | 14 | 15 | class Email(ABC): 16 | 17 | @abstractmethod 18 | def send_mail(self, subject: str, body: str, receivers: List[str], sender: str, link: Optional[str] = None): 19 | pass 20 | -------------------------------------------------------------------------------- /pkg/email/django_mail/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/email/django_mail/__init__.py -------------------------------------------------------------------------------- /pkg/email/django_mail/email.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from django.core.mail import EmailMessage 4 | from django.template.loader import render_to_string 5 | 6 | from pkg.email.base import Email 7 | 8 | 9 | class DjangoMail(Email): 10 | 11 | def send_mail(self, subject: str, body: str, receivers: List[str], sender: str, link: Optional[str] = None): 12 | # use a template for mail 13 | html_message = render_to_string( 14 | 'notification/email-template.html', {"subject": subject, "body": body, "link": link}) 15 | 16 | email = EmailMessage(subject=subject, body=html_message, from_email=sender, to=receivers) 17 | email.content_subtype = 'html' 18 | email.send() 19 | -------------------------------------------------------------------------------- /pkg/email/email.py: -------------------------------------------------------------------------------- 1 | from functools import cache 2 | 3 | from pkg.email.django_mail.email import DjangoMail 4 | 5 | 6 | def django_email(): 7 | return DjangoMail() 8 | 9 | 10 | @cache 11 | def get_email_service(): 12 | return django_email() 13 | -------------------------------------------------------------------------------- /pkg/file.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | 4 | def change_filename(filename: str) -> str: 5 | return f"{uuid4()}.{filename.split('.')[-1]}" 6 | -------------------------------------------------------------------------------- /pkg/logger/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/logger/__init__.py -------------------------------------------------------------------------------- /pkg/logger/base.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | from abc import ABC, abstractmethod 3 | 4 | 5 | class Log(ABC): 6 | 7 | @abstractmethod 8 | def debug(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = dict): 9 | pass 10 | 11 | @abstractmethod 12 | def info(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = dict): 13 | pass 14 | 15 | @abstractmethod 16 | def warn(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = dict): 17 | pass 18 | 19 | @abstractmethod 20 | def error(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = dict): 21 | pass 22 | -------------------------------------------------------------------------------- /pkg/logger/constants.py: -------------------------------------------------------------------------------- 1 | DEBUG = "debug" 2 | INFO = "info" 3 | WARN = "warn" 4 | ERROR = "error" 5 | -------------------------------------------------------------------------------- /pkg/logger/logger.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from django.conf import settings 4 | from pkg.logger.base import Log 5 | from pkg.logger.standard.logger import StandardLogger 6 | 7 | logger_name = getattr(settings, "LOGGER_NAME", "standard") 8 | 9 | 10 | @lru_cache 11 | def _get_standard_logger_once() -> StandardLogger: 12 | return StandardLogger() 13 | 14 | 15 | def get_logger() -> Log: 16 | match logger_name: 17 | case _: 18 | return _get_standard_logger_once() 19 | -------------------------------------------------------------------------------- /pkg/logger/standard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/logger/standard/__init__.py -------------------------------------------------------------------------------- /pkg/logger/standard/logger.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Dict, Optional 4 | 5 | from pkg.logger.base import Log 6 | 7 | 8 | class StandardLogger(Log): 9 | LOGGER_NAME = "syntax" 10 | 11 | def __init__(self): 12 | self.logger = self.get_logger() 13 | 14 | def get_logger(self) -> logging.Logger: 15 | # Create a logger 16 | logger = logging.getLogger(self.LOGGER_NAME) 17 | logger.setLevel(logging.DEBUG) 18 | 19 | # Create a formatter to define the log format 20 | formatter = logging.Formatter( 21 | '%(asctime)s - %(levelname)s - %(message)s - %(app)s - %(category)s - %(sub_category)s - %(meta)s') 22 | 23 | # Create a file handler to write logs to a file 24 | # file_handler = logging.FileHandler('app.log') 25 | # file_handler.setLevel(logging.DEBUG) 26 | # file_handler.setFormatter(formatter) 27 | 28 | # Create a stream handler to print logs to the console 29 | console_handler = logging.StreamHandler() 30 | console_handler.setLevel(logging.DEBUG) 31 | console_handler.setFormatter(formatter) 32 | 33 | # logger.addHandler(file_handler) 34 | logger.addHandler(console_handler) 35 | 36 | return logger 37 | 38 | def debug(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = None): 39 | self.logger.debug( 40 | msg=msg, 41 | extra={"app": app, "category": category, "sub_category": sub_category, 42 | "meta": json.dumps(meta) if meta else {}}) 43 | 44 | def info(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = None): 45 | self.logger.info( 46 | msg=msg, 47 | extra={"app": app, "category": category, "sub_category": sub_category, 48 | "meta": json.dumps(meta) if meta else {}}) 49 | 50 | def warn(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = None): 51 | self.logger.warning( 52 | msg=msg, 53 | extra={"app": app, "category": category, "sub_category": sub_category, 54 | "meta": json.dumps(meta) if meta else {}}) 55 | 56 | def error(self, app: str, msg: str, category: str, sub_category: str, meta: Optional[Dict] = None): 57 | self.logger.error( 58 | msg=msg, 59 | extra={"app": app, "category": category, "sub_category": sub_category, 60 | "meta": json.dumps(meta) if meta else {}}) 61 | -------------------------------------------------------------------------------- /pkg/randomly.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | 4 | def generate_number_code_int(num_digits: int = 6) -> int: 5 | lower_bound = 10 ** (num_digits - 1) 6 | upper_bound = (10 ** num_digits) - 1 7 | 8 | random_number = randint(lower_bound, upper_bound) 9 | 10 | return random_number 11 | 12 | 13 | def generate_number_code_str(num_digits: int = 6) -> str: 14 | random_number = generate_number_code_int(num_digits=num_digits) 15 | return str(random_number) 16 | -------------------------------------------------------------------------------- /pkg/rich_error/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/rich_error/__init__.py -------------------------------------------------------------------------------- /pkg/rich_error/constant.py: -------------------------------------------------------------------------------- 1 | OPERATION = "Operation" 2 | CODE = "Code" 3 | MESSAGE = "Message" 4 | META = "Meta" 5 | DEFAULT_CODE = 500_000 6 | -------------------------------------------------------------------------------- /pkg/rich_error/error.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | 3 | from . import constant 4 | 5 | 6 | def _get_default_code(): 7 | return constant.DEFAULT_CODE 8 | 9 | 10 | class RichError(Exception): 11 | 12 | def __init__(self, operation: str) -> None: 13 | self.operation: str = operation 14 | self._message: Optional[str] = None 15 | self.code: int = _get_default_code() 16 | self.error: Optional[Exception] = None 17 | self.meta: Dict = {} 18 | 19 | def __str__(self) -> str: 20 | return self._message if self._message else str(self.error) if self.error else "" 21 | 22 | def set_error(self, error: Exception): 23 | self.error = error 24 | return self 25 | 26 | def set_msg(self, message: str): 27 | self._message = message 28 | return self 29 | 30 | def set_code(self, code: int): 31 | self.code = code 32 | return self 33 | 34 | def set_meta(self, meta: Dict): 35 | if isinstance(meta, dict): 36 | self.meta = meta 37 | return self 38 | 39 | 40 | def error_code(error: Exception) -> int: 41 | while isinstance(error, RichError) and error.code == _get_default_code() and error.error: 42 | error = error.error 43 | 44 | return _get_default_code() if not isinstance(error, RichError) else error.code 45 | 46 | 47 | def error_meta(error: Exception) -> Dict: 48 | while isinstance(error, RichError) and not error.meta and error.error: 49 | error = error.error 50 | 51 | return {} if not isinstance(error, RichError) else error.meta 52 | 53 | 54 | def _error_info(error: Exception) -> Dict: 55 | if isinstance(error, RichError): 56 | return { 57 | constant.OPERATION: error.operation, 58 | constant.CODE: error_code(error), 59 | constant.MESSAGE: str(error), 60 | constant.META: error_meta(error), 61 | } 62 | 63 | return { 64 | constant.OPERATION: "", 65 | constant.CODE: _get_default_code(), 66 | constant.MESSAGE: str(error), 67 | constant.META: {}, 68 | } 69 | 70 | 71 | def get_error_recursive(error: Exception, errors: List) -> List: 72 | errors.append(_error_info(error)) 73 | if isinstance(error, RichError) and error.error: 74 | return get_error_recursive(error.error, errors) 75 | 76 | return errors 77 | 78 | 79 | def get_error_info(error: Exception) -> List: 80 | return get_error_recursive(error=error, errors=[]) 81 | -------------------------------------------------------------------------------- /pkg/rich_error/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntaxfa/django-structure/3d1f78d5763a31c8f1bf3552d19d9b101e72325e/pkg/rich_error/tests/__init__.py -------------------------------------------------------------------------------- /pkg/rich_error/tests/tests.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pkg.rich_error import constant 4 | from pkg.rich_error.error import RichError, error_code, error_meta, _error_info, get_error_info, _get_default_code 5 | 6 | 7 | class RichErrorTest(TestCase): 8 | 9 | def test_error_one_layer(self): 10 | op = "tests.tests.RichErrorTest.test_error_one_layer" 11 | message = "layer one error occurred" 12 | 13 | err = RichError(operation=op).set_msg(message).set_code(404_000) 14 | 15 | self.assertEqual(str(err), message) 16 | self.assertEqual(error_code(error=err), 404_000) 17 | 18 | def test_error_one_layer_default_variable(self): 19 | op = "tests.tests.RichErrorTest.test_error_one_layer_default_variable" 20 | 21 | err = RichError(operation=op) 22 | 23 | self.assertEqual(str(err), "") 24 | self.assertEqual(error_code(error=err), 500_000) 25 | 26 | def test_error_two_layer_message_default(self): 27 | op = "tests.tests.RichErrorTest.test_error_two_layer_message_default" 28 | 29 | err1 = RichError(op) 30 | err2 = RichError(op).set_error(err1).set_meta({"user_id": 1}) 31 | 32 | self.assertEqual(str(err2), "") 33 | self.assertEqual(error_code(error=err2), constant.DEFAULT_CODE) 34 | self.assertEqual(error_meta(err2), {"user_id": 1}) 35 | 36 | def test_error_two_layer_message_in_layer_one(self): 37 | op = "tests.tests.RichErrorTest.test_error_two_layer_message_default" 38 | 39 | err1 = RichError(op).set_msg("an error").set_meta({"user_id": 1}) 40 | err2 = RichError(op).set_error(err1) 41 | 42 | self.assertEqual(str(err2), "an error") 43 | self.assertEqual(error_code(err2), 500_000) 44 | self.assertEqual(error_meta(err2), {"user_id": 1}) 45 | 46 | def test_error_two_layer_message_custom_in_layer_two(self): 47 | op = "tests.tests.RichErrorTest.test_error_two_layer_message_custom_in_layer_one" 48 | 49 | err1 = RichError(op) 50 | err2 = RichError(op).set_error(err1).set_msg("an error") 51 | 52 | self.assertEqual(str(err2), "an error") 53 | self.assertEqual(error_code(err2), 500_000) 54 | 55 | def test_error_three_layer_message_in_layer_one_layer_one_is_exception(self): 56 | op = "tests.tests.RichErrorTest.test_error_three_layer_message_in_layer_one_layer_one_is_exception" 57 | 58 | err1 = Exception("an error in exception") 59 | err2 = RichError(op).set_error(err1) 60 | err3 = RichError(op).set_error(err2) 61 | 62 | self.assertEqual(str(err3), "an error in exception") 63 | self.assertEqual(error_code(err3), 500_000) 64 | self.assertEqual(error_meta(err3), {}) 65 | 66 | def test_error_code_with_exception(self): 67 | err = Exception() 68 | 69 | self.assertEqual(error_code(err), 500_000) 70 | 71 | def test_error_info_with_exception_error(self): 72 | err = Exception("hello") 73 | info = _error_info(err) 74 | 75 | self.assertEqual(info[constant.OPERATION], "") 76 | self.assertEqual(info[constant.CODE], constant.DEFAULT_CODE) 77 | self.assertEqual(info[constant.MESSAGE], "hello") 78 | self.assertEqual(info[constant.META], {}) 79 | 80 | def test_error_info_with_rich_error_default_value(self): 81 | op = "tests.tests.RichErrorTest.test_error_info_with_rich_error" 82 | err = RichError(op) 83 | info = _error_info(err) 84 | 85 | self.assertEqual(info[constant.OPERATION], "tests.tests.RichErrorTest.test_error_info_with_rich_error") 86 | self.assertEqual(info[constant.CODE], constant.DEFAULT_CODE) 87 | self.assertEqual(info[constant.MESSAGE], "") 88 | self.assertEqual(info[constant.META], {}) 89 | 90 | def test_error_info_with_rich_error_value(self): 91 | op = "tests.tests.RichErrorTest.test_error_info_with_rich_error" 92 | err = (RichError(op).set_code(404_000).set_error(Exception("exception error")).set_meta({"user_id": 1}). 93 | set_msg("hello from test with rich error")) 94 | info = _error_info(err) 95 | 96 | self.assertEqual(info[constant.OPERATION], "tests.tests.RichErrorTest.test_error_info_with_rich_error") 97 | self.assertEqual(info[constant.CODE], 404_000) 98 | self.assertEqual(info[constant.MESSAGE], "hello from test with rich error") 99 | self.assertEqual(info[constant.META], {"user_id": 1}) 100 | 101 | def test_error_three_layer_is_have_value(self): 102 | op = "tests.tests.RichErrorTest.test_error_three_layer_is_have_value" 103 | 104 | err1 = RichError(op).set_msg("error 1").set_code(404_000).set_meta({"user_id": 1}) 105 | err2 = RichError(op).set_error(err1).set_msg("error 2").set_code(429_000).set_meta({"fullname": "alireza"}) 106 | err3 = (RichError(op).set_error(err2).set_msg("error 3").set_code(500_000). 107 | set_meta({"user_id": 1, "last_update": "2024"})) 108 | 109 | self.assertEqual(str(err3), "error 3") 110 | self.assertEqual(error_code(err3), 429_000) 111 | 112 | def test_get_error_info_one_layer_exception(self): 113 | err = Exception("error") 114 | self.assertEqual(get_error_info(err), [ 115 | { 116 | constant.OPERATION: "", 117 | constant.CODE: _get_default_code(), 118 | constant.MESSAGE: "error", 119 | constant.META: {}, 120 | } 121 | ]) 122 | 123 | def test_get_error_info_two_layer_layer_one_exception(self): 124 | op = "tests.tests.RichErrorTest.test_get_error_info_two_layer_layer_one_exception" 125 | 126 | err1 = Exception() 127 | err2 = RichError(op).set_error(err1) 128 | 129 | self.assertEqual(get_error_info(err2), [ 130 | { 131 | constant.OPERATION: op, 132 | constant.CODE: _get_default_code(), 133 | constant.MESSAGE: "", 134 | constant.META: {}, 135 | }, 136 | { 137 | constant.OPERATION: "", 138 | constant.CODE: _get_default_code(), 139 | constant.MESSAGE: "", 140 | constant.META: {}, 141 | }, 142 | ]) 143 | 144 | def test_get_error_info_two_layer_layer_one_exception2(self): 145 | op = "tests.tests.RichErrorTest.test_get_error_info_two_layer_layer_one_exception" 146 | 147 | err1 = Exception("error1") 148 | err2 = RichError(op).set_error(err1).set_meta({"user_id": 1}).set_code(404_000).set_msg("error2") 149 | 150 | self.assertEqual(get_error_info(err2), [ 151 | { 152 | constant.OPERATION: op, 153 | constant.CODE: 404_000, 154 | constant.MESSAGE: "error2", 155 | constant.META: {"user_id": 1}, 156 | }, 157 | { 158 | constant.OPERATION: "", 159 | constant.CODE: _get_default_code(), 160 | constant.MESSAGE: "error1", 161 | constant.META: {}, 162 | }, 163 | ]) 164 | 165 | def test_get_error_info_two_layer_rich_error(self): 166 | op = "tests.tests.RichErrorTest.test_get_error_info_two_layer_layer_one_exception" 167 | 168 | err1 = RichError(op).set_msg("error1").set_code(404_000) 169 | err2 = RichError(op).set_error(err1).set_meta({"user_id": 1}) 170 | 171 | self.assertEqual(get_error_info(err2), [ 172 | { 173 | constant.OPERATION: op, 174 | constant.CODE: 404_000, 175 | constant.MESSAGE: "error1", 176 | constant.META: {"user_id": 1}, 177 | }, 178 | { 179 | constant.OPERATION: op, 180 | constant.CODE: 404_000, 181 | constant.MESSAGE: "error1", 182 | constant.META: {}, 183 | }, 184 | ]) 185 | 186 | def test_get_error_info_three_layer_rich_error(self): 187 | op = "tests.tests.RichErrorTest.test_get_error_info_two_layer_layer_one_exception" 188 | 189 | err1 = RichError(op).set_msg("error1") 190 | err2 = RichError(op).set_error(err1).set_meta({"user_id": 1}).set_code(403_000).set_msg("error2") 191 | err3 = RichError(op).set_error(err2).set_meta({"user_id": 1}) 192 | 193 | self.assertEqual(get_error_info(err3), [ 194 | { 195 | constant.OPERATION: op, 196 | constant.CODE: 403_000, 197 | constant.MESSAGE: "error2", 198 | constant.META: {"user_id": 1}, 199 | }, 200 | { 201 | constant.OPERATION: op, 202 | constant.CODE: 403_000, 203 | constant.MESSAGE: "error2", 204 | constant.META: {"user_id": 1}, 205 | }, 206 | { 207 | constant.OPERATION: op, 208 | constant.CODE: _get_default_code(), 209 | constant.MESSAGE: "error1", 210 | constant.META: {}, 211 | }, 212 | ]) 213 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.13" 7 | 8 | python: 9 | install: 10 | - requirements: requirements/production.txt 11 | 12 | sphinx: 13 | configuration: config/settings/production.py -------------------------------------------------------------------------------- /redis.conf: -------------------------------------------------------------------------------- 1 | port ${REDIS_PORT} 2 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | Django==5.2 2 | djangorestframework-simplejwt==5.5.0 3 | python-dotenv==1.1.1 4 | django-redis==6.0.0 5 | djangorestframework==3.16.0 6 | drf-spectacular==0.28.0 7 | celery==5.5.3 8 | django-celery-beat==2.8.1 9 | psycopg2-binary==2.9.10 -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | django-debug-toolbar==5.2.0 3 | pylint==3.3.7 4 | pre-commit==4.2.0 5 | gitlint==0.19.1 -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | django-cors-headers==4.7.0 3 | gunicorn==23.0.0 4 | gevent==25.5.1 5 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | model-bakery==1.20.5 3 | pylint==3.3.7 --------------------------------------------------------------------------------