commit f54518c6e42bb323527ffd2ed273407b0a01f9e3 Author: ITQ Date: Wed Feb 26 17:43:23 2025 +0300 init: added backend boilerplate diff --git a/services/backend/.dockerignore b/services/backend/.dockerignore new file mode 100644 index 0000000..6886653 --- /dev/null +++ b/services/backend/.dockerignore @@ -0,0 +1,185 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# PyPI configuration file +.pypirc + +# Ruff files +.ruff_cache + +# Docker files +Dockerfile +Dockerfile.staticfiles +.dockerignore + +# Git files +.git +.gitignore + +# Template env file +.env.template + +# Collected static files +static diff --git a/services/backend/.env.template b/services/backend/.env.template new file mode 100644 index 0000000..1b44f64 --- /dev/null +++ b/services/backend/.env.template @@ -0,0 +1,31 @@ +# Change all vars before going to production and remove all comments (!) +# Below all environment variables and default values + +DJANGO_SECRET_KEY=very_insecure_key +DJANGO_DEBUG=False +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 +DJANGO_CORS_ALLOWED_ORIGINS=* +DJANGO_INTERNAL_IPS=127.0.0.1 +DJANGO_LANGUAGE_CODE=en-us +DJANGO_STATIC_URL=static/ +REDIS_URI=redis://localhost:6379 +DJANGO_DB_URI=sqlite:///db.sqlite3 + + +# Storages + +MINIO_ENDPOINT= +MINIO_CUSTOM_ENDPOINT_URL= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_USE_HTTPS=False +MINIO_MEDIA_BUCKET_NAME=project_name-media + + +# Applyable if you installing using docker compose + +DJANGO_CREATE_SUPERUSER=False +DJANGO_SUPERUSER_USERNAME= +DJANGO_SUPERUSER_EMAIL= +DJANGO_SUPERUSER_PASSWORD= diff --git a/services/backend/.gitignore b/services/backend/.gitignore new file mode 100644 index 0000000..657dc4c --- /dev/null +++ b/services/backend/.gitignore @@ -0,0 +1,173 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# PyPI configuration file +.pypirc + +# Ruff files +.ruff_cache + +# Collected static files +static diff --git a/services/backend/Dockerfile b/services/backend/Dockerfile new file mode 100644 index 0000000..b55f660 --- /dev/null +++ b/services/backend/Dockerfile @@ -0,0 +1,42 @@ +# Stage 1: Install dependencies +FROM docker.io/python:3.11-alpine3.20 AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/ + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONOPTIMIZE=2 \ + UV_COMPILE_BYTECODE=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv + +COPY pyproject.toml . + +RUN uv sync --no-dev --no-install-project --no-cache + + +# Stage 2: Start the application +FROM docker.io/python:3.11-alpine3.20 + +WORKDIR /app + +COPY --from=builder /opt/venv /opt/venv + +COPY . . + +RUN adduser -D -g '' app && chown -R app:app ./ + +USER app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONOPTIMIZE=2 \ + PATH="/opt/venv/bin:$PATH" + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health?format=json || exit 1 + +CMD gunicorn config.wsgi --workers=8 -b 0.0.0.0:8080 --access-logfile - --error-logfile - diff --git a/services/backend/Dockerfile.staticfiles b/services/backend/Dockerfile.staticfiles new file mode 100644 index 0000000..5150bf5 --- /dev/null +++ b/services/backend/Dockerfile.staticfiles @@ -0,0 +1,27 @@ +# Stage 1: Install dependencies and compile staticfiles +FROM docker.io/python:3.11-alpine3.20 AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/ + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONOPTIMIZE=2 \ + UV_COMPILE_BYTECODE=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv + +COPY pyproject.toml . + +RUN uv sync --no-dev --no-install-project --no-cache + +COPY . . + +RUN uv run python manage.py collectstatic --noinput + +# Stage 2: Start nginx and serve staticfiles +FROM docker.io/nginx:latest + +COPY --from=builder /app/static /usr/share/nginx/html + +CMD ["nginx", "-g", "daemon off;"] diff --git a/services/backend/README.md b/services/backend/README.md new file mode 100644 index 0000000..7a06d20 --- /dev/null +++ b/services/backend/README.md @@ -0,0 +1,147 @@ +# project_name Backend + +## Prerequisites + +Ensure you have the following installed on your system: + +- [Python](https://www.python.org/) (>=3.10,<3.12) +- [uv](https://docs.astral.sh/uv/) +- [Docker](https://www.docker.com/) (for containerized setup) + +## Basic setup + +### Installation + +#### Clone the project + +```bash +git clone project_name +``` + +#### Go to the project directory + +```bash +cd project_name/services/backend +``` + +#### Customize environment + +```bash +cp .env.template .env +``` + +And setup env vars according to your needs. + +#### Install dependencies + +##### For dev environment + +```bash +uv sync --all-extras +``` + +##### For prod environment + +```bash +uv sync --no-dev +``` + +#### Running + +##### Apply migrations + +```bash +uv run python manage.py migrate +``` + +##### Start celery worker + +```bash +celery -A config worker -l INFO +``` + +##### Start server + +In dev mode: + +```bash +uv run python manage.py runserver +``` + +In prod mode: + +```bash +uv run gunicorn config.wsgi +``` + +## Containerized setup + +### Clone the project + +```bash +git clone project_name +``` + +### Go to the project directory + +```bash +cd project_name/services/backend +``` + +### Build docker image + +```bash +docker build -t project_name-backend . +``` + +### Customize environment + +Customize environment with `docker run` command (or bind .env file to container), for all environment vars and default values see [.env.template](./.env.template). + +### Run docker image + +#### Backend + +```bash +docker run -p 8080:8080 --name project_name-backend project_name-backend +``` + +#### Celery worker + +```bash +docker run --name project_name-celery-worker project_name-backend celery -A config worker -l INFO +``` + +Backend will be available on [127.0.0.1:8080](http://127.0.0.1:8080). + +## Testing + +### Clone the project + +```bash +git clone project_name +``` + +### Go to the project directory + +```bash +cd project_name/services/backend +``` + +### Install dependencies + +```bash +uv sync --all-extras +``` + +### Run tests + +```bash +uv run coverage run --source="." manage.py test +``` + +### Check coverage + +```bash +uv run coverage report +``` diff --git a/services/backend/api/__init__.py b/services/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/urls.py b/services/backend/api/urls.py new file mode 100644 index 0000000..ffeb361 --- /dev/null +++ b/services/backend/api/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from health_check.views import MainView + +from api.v1.router import router as api_v1_router + +urlpatterns = [ + path("", api_v1_router.urls), + # Health endpoint + path("health", MainView.as_view(), name="health_check_home"), +] diff --git a/services/backend/api/v1/__init__.py b/services/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/handlers.py b/services/backend/api/v1/handlers.py new file mode 100644 index 0000000..d09fe0a --- /dev/null +++ b/services/backend/api/v1/handlers.py @@ -0,0 +1,121 @@ +import logging +from collections.abc import Callable +from http import HTTPStatus as status +from typing import Any + +import django.core.exceptions +import django.http +import ninja.errors +from django.http import HttpRequest, HttpResponse +from ninja import NinjaAPI + +from config.errors import ConflictError, ForbiddenError + +logger = logging.getLogger("django") + + +def handle_validation_error( + request: HttpRequest, + exc: ninja.errors.ValidationError, + router: NinjaAPI, +) -> HttpResponse: + return router.create_response( + request, + {"detail": exc.errors}, + status=status.BAD_REQUEST, + ) + + +def handle_django_validation_error( + request: HttpRequest, + exc: django.core.exceptions.ValidationError, + router: NinjaAPI, +) -> HttpResponse: + detail = list(exc) + + if hasattr(exc, "error_dict"): + detail = dict(exc) + + return router.create_response( + request, + {"detail": detail}, + status=status.BAD_REQUEST, + ) + + +def handle_authentication_error( + request: HttpRequest, + exc: ninja.errors.AuthenticationError, + router: NinjaAPI, +) -> HttpResponse: + return router.create_response( + request, + {"detail": status.UNAUTHORIZED.phrase}, + status=status.UNAUTHORIZED, + ) + + +def handle_forbidden_error( + request: HttpRequest, + exc: ForbiddenError, + router: NinjaAPI, +) -> HttpResponse: + return router.create_response( + request, + {"detail": exc.message}, + status=status.FORBIDDEN, + ) + + +def handle_not_found_error( + request: HttpRequest, + exc: Exception, + router: NinjaAPI, +) -> HttpResponse: + return router.create_response( + request, + {"detail": status.NOT_FOUND.phrase}, + status=status.NOT_FOUND, + ) + + +def handle_conflict_error( + request: HttpRequest, + exc: ConflictError, + router: NinjaAPI, +) -> HttpResponse: + detail = list(exc.validation_error) + + if hasattr(exc, "error_dict"): + detail = dict(exc.validation_error) + + return router.create_response( + request, + {"detail": detail}, + status=status.CONFLICT, + ) + + +def handle_unknown_exception( + request: HttpRequest, + exc: Exception, + router: NinjaAPI, +) -> HttpResponse: + logger.exception(exc) + + return router.create_response( + request, + {"detail": status.INTERNAL_SERVER_ERROR.phrase}, + status=status.INTERNAL_SERVER_ERROR, + ) + + +exception_handlers: list[tuple[Any, Callable]] = [ + (ninja.errors.ValidationError, handle_validation_error), + (django.core.exceptions.ValidationError, handle_django_validation_error), + (ninja.errors.AuthenticationError, handle_authentication_error), + (ForbiddenError, handle_forbidden_error), + (django.http.Http404, handle_not_found_error), + (ConflictError, handle_conflict_error), + (Exception, handle_unknown_exception), +] diff --git a/services/backend/api/v1/ping/__init__.py b/services/backend/api/v1/ping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/ping/schemas.py b/services/backend/api/v1/ping/schemas.py new file mode 100644 index 0000000..07b7eb8 --- /dev/null +++ b/services/backend/api/v1/ping/schemas.py @@ -0,0 +1,5 @@ +from ninja import Schema + + +class PingOut(Schema): + status: str = "ok" diff --git a/services/backend/api/v1/ping/views.py b/services/backend/api/v1/ping/views.py new file mode 100644 index 0000000..a6d78d7 --- /dev/null +++ b/services/backend/api/v1/ping/views.py @@ -0,0 +1,18 @@ +from http import HTTPStatus as status + +from django.http import HttpRequest +from ninja import Router + +from api.v1.ping import schemas + +router = Router(tags=["ping"]) + + +@router.get( + "", + response={ + status.OK: schemas.PingOut, + }, +) +def ping(request: HttpRequest) -> tuple[status, schemas.PingOut]: + return status.OK, schemas.PingOut diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py new file mode 100644 index 0000000..d775d37 --- /dev/null +++ b/services/backend/api/v1/router.py @@ -0,0 +1,23 @@ +from functools import partial + +from ninja import NinjaAPI + +from api.v1 import handlers +from api.v1.ping.views import router as ping_router + +router = NinjaAPI( + title="project_name API", + version="1", + description="API docs for project_name", + openapi_url="/docs/openapi.json", +) + + +router.add_router( + "ping", + ping_router, +) + + +for exception, handler in handlers.exception_handlers: + router.add_exception_handler(exception, partial(handler, router=router)) diff --git a/services/backend/api/v1/schemas.py b/services/backend/api/v1/schemas.py new file mode 100644 index 0000000..c58b291 --- /dev/null +++ b/services/backend/api/v1/schemas.py @@ -0,0 +1,24 @@ +from http import HTTPStatus as status +from typing import Any + +from ninja import Schema + + +class BadRequestError(Schema): + detail: Any + + +class UnauthorizedError(Schema): + detail: str = status.UNAUTHORIZED.phrase + + +class ForbiddenError(Schema): + detail: str = status.FORBIDDEN.phrase + + +class NotFoundError(Schema): + detail: str = status.NOT_FOUND.phrase + + +class ConflictError(Schema): + detail: Any diff --git a/services/backend/apps/__init__.py b/services/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/core/__init__.py b/services/backend/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/core/apps.py b/services/backend/apps/core/apps.py new file mode 100644 index 0000000..3f33d30 --- /dev/null +++ b/services/backend/apps/core/apps.py @@ -0,0 +1,13 @@ +import contextlib + +from django.apps import AppConfig +from django.core.cache import cache + + +class CoreConfig(AppConfig): + name = "apps.core" + label = "core" + + def ready(self) -> None: + with contextlib.suppress(Exception): + cache.add("current_date", 0, timeout=None) diff --git a/services/backend/apps/core/models.py b/services/backend/apps/core/models.py new file mode 100644 index 0000000..27daed8 --- /dev/null +++ b/services/backend/apps/core/models.py @@ -0,0 +1,48 @@ +import uuid +from typing import Any + +from django.core.exceptions import ValidationError +from django.db import models + +from config.errors import ConflictError + + +class BaseModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + def save(self, *args: Any, **kwargs: Any) -> None: + self.validate() + + super().save(*args, **kwargs) + + def validate( + self, + validate_unique: bool = True, + validate_constraints: bool = True, + include: list[models.Field] | None = None, + ) -> None: + self.full_clean( + validate_unique=False, + validate_constraints=False, + exclude=( + field.name + for field in set(self._meta.get_fields()) - set(include) + ) + if include + else None, + ) + + if validate_unique: + try: + self.validate_unique() + except ValidationError as e: + raise ConflictError(e) from None + + if validate_constraints: + try: + self.validate_constraints() + except ValidationError as e: + raise ConflictError(e) from None diff --git a/services/backend/config/__init__.py b/services/backend/config/__init__.py new file mode 100644 index 0000000..3eb91a6 --- /dev/null +++ b/services/backend/config/__init__.py @@ -0,0 +1,3 @@ +from config.celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/services/backend/config/asgi.py b/services/backend/config/asgi.py new file mode 100644 index 0000000..513bb2b --- /dev/null +++ b/services/backend/config/asgi.py @@ -0,0 +1,9 @@ +"""ASGI config for project_name.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/services/backend/config/celery.py b/services/backend/config/celery.py new file mode 100644 index 0000000..ecad09a --- /dev/null +++ b/services/backend/config/celery.py @@ -0,0 +1,10 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +app = Celery("project_name") + +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/services/backend/config/errors.py b/services/backend/config/errors.py new file mode 100644 index 0000000..9729b26 --- /dev/null +++ b/services/backend/config/errors.py @@ -0,0 +1,13 @@ +from http import HTTPStatus as status + +from django.core.exceptions import ValidationError + + +class ConflictError(Exception): + def __init__(self, validation_error: ValidationError) -> None: + self.validation_error = validation_error + + +class ForbiddenError(Exception): + def __init__(self, message: str = status.FORBIDDEN.phrase) -> None: + self.message = message diff --git a/services/backend/config/handlers.py b/services/backend/config/handlers.py new file mode 100644 index 0000000..7adeead --- /dev/null +++ b/services/backend/config/handlers.py @@ -0,0 +1,43 @@ +from http import HTTPStatus as status + +from django.http import HttpRequest, JsonResponse + + +def handler400( + request: HttpRequest, + exception: Exception | None = None, +) -> JsonResponse: + return JsonResponse( + status=status.BAD_REQUEST, + data={"detail": status.BAD_REQUEST.phrase}, + ) + + +def handler403( + request: HttpRequest, + exception: Exception | None = None, +) -> JsonResponse: + return JsonResponse( + status=status.FORBIDDEN, + data={"detail": status.FORBIDDEN.phrase}, + ) + + +def handler404( + request: HttpRequest, + exception: Exception | None = None, +) -> JsonResponse: + return JsonResponse( + status=status.NOT_FOUND, + data={"detail": status.NOT_FOUND.phrase}, + ) + + +def handler500( + request: HttpRequest, + exception: Exception | None = None, +) -> JsonResponse: + return JsonResponse( + status=status.INTERNAL_SERVER_ERROR, + data={"detail": status.INTERNAL_SERVER_ERROR.phrase}, + ) diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py new file mode 100644 index 0000000..091dfe9 --- /dev/null +++ b/services/backend/config/settings.py @@ -0,0 +1,587 @@ +"""Django settings for project_name.""" + +import contextlib +import logging +from collections.abc import Callable +from pathlib import Path + +import django_stubs_ext +import environ +from django.utils.translation import gettext_lazy as _ + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env() +environ.Env.read_env(BASE_DIR / ".env") + +django_stubs_ext.monkeypatch() + + +# Common settings + +DEBUG = env("DJANGO_DEBUG", default=False) + +ALLOWED_HOSTS = env( + "DJANGO_ALLOWED_HOSTS", + list, + default=["localhost", "127.0.0.1"], +) + + +# Integrations + +YANDEX_CLOUD_FOLDER_ID = env("YANDEX_CLOUD_FOLDER_ID", default=None) + +YANDEX_CLOUD_API_KEY = env("YANDEX_CLOUD_API_KEY", default=None) + +YANDEX_CLOUD_INTEGRATION_ENABLED = ( + YANDEX_CLOUD_FOLDER_ID and YANDEX_CLOUD_API_KEY +) + + +# Register healthchecks + +# plugin_dir.register(SomeHealthCheckClass) + + +# Caching + +REDIS_URI = env("REDIS_URI", default="redis://localhost:6379") + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": REDIS_URI, + "TIMEOUT": None, + "KEY_PREFIX": "backend", + "VERSION": 1, + }, +} + + +# Celery + +CELERY_BROKER_URL = REDIS_URI + +CELERY_RESULT_BACKEND = REDIS_URI + +CELERY_TIMEZONE = "UTC" + +CELERY_WORKER_SEND_TASK_EVENTS = True + +CELERY_TASK_SEND_SENT_EVENT = True + +CELERY_TASK_TRACK_STARTED = True + + +# Database + +DB_URI = env.db_url("DJANGO_DB_URI", default="sqlite:///db.sqlite3") + +DATABASES = {"default": {**DB_URI, "CONN_MAX_AGE": 50}} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": ( + "django.contrib.auth." + "password_validation.UserAttributeSimilarityValidator" + ), + }, + { + "NAME": ( + "django.contrib.auth.password_validation.MinimumLengthValidator" + ), + }, + { + "NAME": ( + "django.contrib.auth.password_validation.CommonPasswordValidator" + ), + }, + { + "NAME": ( + "django.contrib.auth.password_validation.NumericPasswordValidator" + ), + }, +] + + +# Static files (CSS, JavaScript, Images) + +STATIC_ROOT = BASE_DIR / "static" + +STATIC_URL = env("DJANGO_STATIC_URL", default="static/") + +STATICFILES_DIRS: list[str] = [] + +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + + +# Files + +FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440 + +# Minio + +MINIO_STORAGE_ENDPOINT = env("MINIO_ENDPOINT", default=None) + +MINIO_STORAGE_ACCESS_KEY = env("MINIO_ACCESS_KEY", default=None) + +MINIO_STORAGE_SECRET_KEY = env("MINIO_SECRET_KEY", default=None) + +MINIO_STORAGE_USE_HTTPS = env("MINIO_USE_HTTPS", default=False) + +MINIO_STORAGE_MEDIA_BUCKET_NAME = env( + "MINIO_MEDIA_BUCKET_NAME", default="project_name-media" +) + +MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True + +MINIO_STORAGE_AUTO_CREATE_MEDIA_POLICY = "GET_ONLY" + +MINIO_DEFAULT_CUSTOM_ENDPOINT_URL = ( + "https://" + if MINIO_STORAGE_USE_HTTPS + else "http://" + str(MINIO_STORAGE_ENDPOINT) +) + +MINIO_STORAGE_MEDIA_URL = ( + env("MINIO_CUSTOM_ENDPOINT_URL", default=MINIO_DEFAULT_CUSTOM_ENDPOINT_URL) + + "/" + f"{MINIO_STORAGE_MEDIA_BUCKET_NAME}" +) + +MINIO_STORAGE_DEFAULT_ACL = "public-read" + +STORAGES = { + "default": { + "BACKEND": "minio_storage.storage.MinioMediaStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + + +# Cors + +CORS_ALLOWED_ORIGINS_FROM_ENV = env("DJANGO_CORS_ALLOWED_ORIGINS", list, ["*"]) + +if CORS_ALLOWED_ORIGINS_FROM_ENV == ["*"]: + CORS_ALLOW_ALL_ORIGINS = True +else: + CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS_FROM_ENV + + +# Forms + +FORM_RENDERER = "django.forms.renderers.DjangoTemplates" + +FORMS_URLFIELD_ASSUME_HTTPS = False + + +# Internationalization + +DATE_FORMAT = "N j, Y" + +DATE_INPUT_FORMATS = [ + "%Y-%m-%d", # '2006-10-25' + "%m/%d/%Y", # '10/25/2006' + "%m/%d/%y", # '10/25/06' + "%b %d %Y", # 'Oct 25 2006' + "%b %d, %Y", # 'Oct 25, 2006' + "%d %b %Y", # '25 Oct 2006' + "%d %b, %Y", # '25 Oct, 2006' + "%B %d %Y", # 'October 25 2006' + "%B %d, %Y", # 'October 25, 2006' + "%d %B %Y", # '25 October 2006' + "%d %B, %Y", # '25 October, 2006' +] + +DATETIME_FORMAT = "N j, Y, H:i:s" + +DATETIME_INPUT_FORMATS = [ + "%Y-%m-%d %H:%M:%S", # '2006-10-25 14:30:59' + "%Y-%m-%d %H:%M:%S.%f", # '2006-10-25 14:30:59.000200' + "%Y-%m-%d %H:%M", # '2006-10-25 14:30' + "%m/%d/%Y %H:%M:%S", # '10/25/2006 14:30:59' + "%m/%d/%Y %H:%M:%S.%f", # '10/25/2006 14:30:59.000200' + "%m/%d/%Y %H:%M", # '10/25/2006 14:30' + "%m/%d/%y %H:%M:%S", # '10/25/06 14:30:59' + "%m/%d/%y %H:%M:%S.%f", # '10/25/06 14:30:59.000200' + "%m/%d/%y %H:%M", # '10/25/06 14:30' +] + +DECIMAL_SEPARATOR = "." + +FIRST_DAY_OF_WEEK = 1 + +FORMAT_MODULE_PATH = None + +LANGUAGE_CODE = env("DJANGO_LANGUAGE_CODE", default="en-us") + +LANGUAGES = [("en", _("English")), ("ru", _("Russian"))] + +LOCALE_PATHS: list[str] = [] + +MONTH_DAY_FORMAT = "F j" + +NUMBER_GROUPING = 0 + +SHORT_DATE_FORMAT = "m/d/Y" + +SHORT_DATETIME_FORMAT = "m/d/Y H:i:s" + +THOUSAND_SEPARATOR = "," + +TIME_FORMAT = "H:i:s" + +TIME_INPUT_FORMATS = [ + "%H:%M:%S", # '14:30:59' + "%H:%M:%S.%f", # '14:30:59.000200' + "%H:%M", # '14:30' +] + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_THOUSAND_SEPARATOR = True + +USE_TZ = True + +YEAR_MONTH_FORMAT = "F Y" + + +# HTTP + +DATA_UPLOAD_MAX_MEMORY_SIZE = None + +DATA_UPLOAD_MAX_NUMBER_FIELDS = None + +DATA_UPLOAD_MAX_NUMBER_FILES = None + +DEFAULT_CHARSET = "utf-8" + +FORCE_SCRIPT_NAME = None + +INTERNAL_IPS = env( + "DJANGO_INTERNAL_IPS", + list, + default=["127.0.0.1"], +) + +MIDDLEWARE = [ + "django_guid.middleware.guid_middleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +SIGNING_BACKEND = "django.core.signing.TimestampSigner" + +USE_X_FORWARDED_HOST = False + +USE_X_FORWARDED_PORT = False + +WSGI_APPLICATION = "config.wsgi.application" + + +# Logging + +LOGGER_NAME = "project_name" + +LOGGER = logging.getLogger(LOGGER_NAME) + +LOGGING_FILTERS = { + "require_debug_true": { + "()": "django.utils.log.RequireDebugTrue", + }, + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", + }, + "correlation_id": { + "()": "django_guid.log_filters.CorrelationId", + }, +} + +LOGGING_FORMATTERS = { + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "format": ( + "{levelname}{correlation_id}{asctime}" + "{name}{pathname}{lineno}{message}" + ), + "style": "{", + }, + "text": { + "()": "colorlog.ColoredFormatter", + "format": ( + "{log_color}[{levelname}]{reset} " + "{light_black}{asctime} {name} | {pathname}:{lineno}{reset}\n" + "{bold_black}{message}{reset}" + ), + "log_colors": { + "DEBUG": "bold_green", + "INFO": "bold_cyan", + "WARNING": "bold_yellow", + "ERROR": "bold_red", + "CRITICAL": "bold_purple", + }, + "style": "{", + }, +} + +LOGGING_HANDLERS = { + "console_debug": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "filters": ["require_debug_true"], + "formatter": "text", + }, + "console_prod": { + "class": "logging.StreamHandler", + "level": "INFO", + "filters": ["require_debug_false", "correlation_id"], + "formatter": "json", + }, +} + +LOGGING_LOGGERS = { + "django": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, + "django.request": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, + "django.server": { + "handlers": ["console_debug"], + "level": "INFO", + "filters": ["require_debug_true"], + "propagate": False, + }, + "django.template": {"handlers": []}, + "django.db.backends.schema": {"handlers": []}, + "django.security": {"handlers": [], "propagate": True}, + "django.db.backends": { + "handlers": ["console_debug"], + "filters": ["require_debug_true"], + "level": "DEBUG", + "propagate": False, + }, + "health-check": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, + LOGGER_NAME: { + "handlers": ["console_debug", "console_prod"], + "level": "DEBUG" if DEBUG else "INFO", + "propagate": False, + }, + "root": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": LOGGING_FILTERS, + "formatters": LOGGING_FORMATTERS, + "handlers": LOGGING_HANDLERS, + "loggers": LOGGING_LOGGERS, +} + +LOGGING_CONFIG = "logging.config.dictConfig" + + +# Models + +ABSOLUTE_URL_OVERRIDES: dict[str, Callable] = {} + +FIXTURE_DIRS: list[str] = [] + +INSTALLED_APPS = [ + # Build-in apps + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Healthcheck + "health_check", + "health_check.db", + "health_check.cache", + "health_check.storage", + "health_check.contrib.migrations", + "health_check.contrib.celery", + "health_check.contrib.celery_ping", + # Third-party apps + "corsheaders", + "django_extensions", + "django_guid", + "ninja", + "minio_storage", + # Internal apps + "apps.core", +] + +# GUID + +DJANGO_GUID = { + "GUID_HEADER_NAME": "Correlation-ID", + "VALIDATE_GUID": True, + "RETURN_HEADER": True, + "EXPOSE_HEADER": True, + "INTEGRATIONS": [], + "IGNORE_URLS": [], + "UUID_LENGTH": 32, +} + + +# Security + +LANGUAGE_COOKIE_AGE = 31449600 + +LANGUAGE_COOKIE_DOMAIN = None + +LANGUAGE_COOKIE_HTTPONLY = False + +LANGUAGE_COOKIE_NAME = "django_language" + +LANGUAGE_COOKIE_PATH = "/" + +LANGUAGE_COOKIE_SAMESITE = "Lax" + +LANGUAGE_COOKIE_SECURE = False + +SECURE_PROXY_SSL_HEADER = None + +CSRF_COOKIE_AGE = 31449600 + +CSRF_COOKIE_DOMAIN = None + +CSRF_COOKIE_HTTPONLY = False + +CSRF_COOKIE_NAME = "djangocsrftoken" + +CSRF_COOKIE_PATH = "/" + +CSRF_COOKIE_SAMESITE = "Lax" + +CSRF_COOKIE_SECURE = False + +CSRF_FAILURE_VIEW = "django.views.csrf.csrf_failure" + +CSRF_HEADER_NAME = "HTTP_X_CSRFTOKEN" + +CSRF_TRUSTED_ORIGINS = env( + "DJANGO_CSRF_TRUSTED_ORIGINS", + list, + default=["http://localhost", "http://127.0.0.1"], +) + +CSRF_USE_SESSIONS = False + +SECRET_KEY = env("DJANGO_SECRET_KEY", default="very_insecure_key") + +SECRET_KEY_FALLBACKS: list[str] = [] + + +# Sessions + +SESSION_CACHE_ALIAS = "default" + +SESSION_COOKIE_AGE = 1209600 + +SESSION_COOKIE_DOMAIN = None + +SESSION_COOKIE_HTTPONLY = True + +SESSION_COOKIE_NAME = "djangosessionid" + +SESSION_COOKIE_PATH = "/" + +SESSION_COOKIE_SAMESITE = "Lax" + +SESSION_COOKIE_SECURE = False + +SESSION_ENGINE = "django.contrib.sessions.backends.db" + +SESSION_EXPIRE_AT_BROWSER_CLOSE = False + +SESSION_FILE_PATH = None + +SESSION_SAVE_EVERY_REQUEST = False + +SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer" + + +# Templates + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "autoescape": True, + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + "debug": DEBUG, + "string_if_invalid": "", + "file_charset": "utf-8", + }, + }, +] + + +# Testing + +TEST_NON_SERIALIZED_APPS: list[str] = [] + +TEST_RUNNER = "django.test.runner.DiscoverRunner" + + +# URLs + +ROOT_URLCONF = "config.urls" + + +# debug-toolbar + +DEBUG_TOOLBAR_ENABLED = False + +with contextlib.suppress(Exception): + import debug_toolbar # noqa: F401 + + DEBUG_TOOLBAR_ENABLED = True + +DEBUG_TOOLBAR_CONFIG = {"SHOW_COLLAPSED": True, "UPDATE_ON_FETCH": True} + +if DEBUG and DEBUG_TOOLBAR_ENABLED: + INSTALLED_APPS.append("debug_toolbar") + MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py new file mode 100644 index 0000000..4dc0330 --- /dev/null +++ b/services/backend/config/urls.py @@ -0,0 +1,34 @@ +"""URL configuration for project_name.""" + +from django.conf import settings +from django.contrib import admin +from django.urls import include, path + +from config import handlers + +admin.site.site_title = "project_name" +admin.site.site_header = "project_name" +admin.site.index_title = "project_name" + + +urlpatterns = [ + # Admin urls + path("admin/", admin.site.urls), + # API urls + path("", include("api.urls")), +] + + +if settings.DEBUG and settings.DEBUG_TOOLBAR_ENABLED: + from debug_toolbar.toolbar import debug_toolbar_urls + + urlpatterns += debug_toolbar_urls() + + +handler400 = handlers.handler400 + +handler403 = handlers.handler403 + +handler404 = handlers.handler404 + +handler500 = handlers.handler500 diff --git a/services/backend/config/wsgi.py b/services/backend/config/wsgi.py new file mode 100644 index 0000000..478b263 --- /dev/null +++ b/services/backend/config/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI config for project_name.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/services/backend/manage.py b/services/backend/manage.py new file mode 100755 index 0000000..f821d3a --- /dev/null +++ b/services/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main() -> None: + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + e = """Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?""" + raise ImportError(e) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml new file mode 100644 index 0000000..fc82dc8 --- /dev/null +++ b/services/backend/pyproject.toml @@ -0,0 +1,160 @@ +[project] +name = "project_name-backend" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.10,<3.12" +dependencies = [ + "celery>=5.4.0", + "colorlog>=6.9.0", + "django-cors-headers>=4.6.0", + "django-environ>=0.11.2", + "django-extensions>=3.2.3", + "django-guid>=3.5.0", + "django-health-check>=3.18.3", + "django-minio-storage>=0.5.7", + "django-ninja>=1.3.0", + "django-stubs-ext>=5.1.3", + "gunicorn>=23.0.0", + "httpx>=0.28.1", + "pillow>=11.1.0", + "psycopg2-binary>=2.9.10", + "pydantic>=2.10.5", + "pyjwt>=2.10.1", + "python-json-logger>=3.2.1", + "pytz>=2024.2", + "redis>=5.2.1", +] + +[dependency-groups] +dev = [ + "coverage>=7.6.12", + "django-debug-toolbar>=4.4.6", + "django-stubs[compatible-mypy]>=5.1.3", + "mypy>=1.15.0", + "ruff>=0.9.3", +] + +[tool.ruff] +builtins = [] +cache-dir = ".ruff_cache" +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "dist", + "migrations", + "node_modules", + "venv", +] +extend-exclude = [] +extend-include = [] +fix = false +fix-only = false +force-exclude = true +include = ["*.py", "*.pyi", "*.ipynb", "**/pyproject.toml"] +indent-width = 4 +line-length = 79 +namespace-packages = [] +output-format = "full" +preview = false +required-version = ">=0.8.4" +respect-gitignore = true +show-fixes = true +src = [".", "src"] +target-version = "py310" +unsafe-fixes = false + +[tool.ruff.analyze] +detect-string-imports = true +direction = "Dependencies" +exclude = [] +include-dependencies = {} +preview = false + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 79 +exclude = [] +indent-style = "space" +line-ending = "lf" +preview = false +quote-style = "double" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +allowed-confusables = ["ℹ"] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +exclude = ["tests.py"] +explicit-preview-rules = false +extend-fixable = [] +extend-per-file-ignores = {} +extend-safe-fixes = [] +extend-select = [] +extend-unsafe-fixes = [] +external = [] +fixable = ["ALL"] +ignore = [ + "ARG", + "D", + "ANN401", + "COM812", + "DJ001", + "DJ007", + "FBT001", + "FBT002", + "N813", + "PLR2004", + "PT009", + "PT027", + "RUF001", + "S311", +] +logger-objects = [] +per-file-ignores = {} +preview = false +select = ["ALL"] +task-tags = ["TODO", "FIXME", "HACK", "WORKOUT"] +typing-modules = [] +unfixable = [] + +[tool.ruff.lint.pylint] +max-args = 6 + +[tool.mypy] +plugins = ["mypy_django_plugin.main"] +ignore_missing_imports = true +strict = false +show_error_context = false +no_implicit_optional = false + +[tool.django-stubs] +django_settings_module = "config.settings" +strict_settings = false + +[tool.coverage.run] +omit = [ + "manage.py", + "config/wsgi.py", + "config/asgi.py", + "config/urls.py", + "config/settings.py", + "config/handlers.py", + "config/errors.py", +] + +[tool.coverage.report] +skip_covered = true diff --git a/services/backend/scripts/check b/services/backend/scripts/check new file mode 100755 index 0000000..f72deb4 --- /dev/null +++ b/services/backend/scripts/check @@ -0,0 +1,12 @@ +#!/bin/sh + +GREEN='\033[1;32m' +NC='\033[0m' + +uvx ruff format . +uvx ruff check . --fix +printf "${GREEN}Linters/formatters runned${NC}\n" + +uv run python manage.py makemigrations --check +uv run python manage.py test +printf "${GREEN}Tests runned${NC}\n" diff --git a/services/backend/scripts/initdb b/services/backend/scripts/initdb new file mode 100755 index 0000000..f2d64eb --- /dev/null +++ b/services/backend/scripts/initdb @@ -0,0 +1,11 @@ +#!/bin/sh + +python manage.py migrate +if [ $? -ne 0 ]; then + echo "Migration failed" + exit 1 +fi + +if [ "$DJANGO_CREATE_SUPERUSER" = "True" ]; then + python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true +fi