From f54518c6e42bb323527ffd2ed273407b0a01f9e3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 26 Feb 2025 17:43:23 +0300 Subject: [PATCH 01/20] init: added backend boilerplate --- services/backend/.dockerignore | 185 +++++++ services/backend/.env.template | 31 ++ services/backend/.gitignore | 173 +++++++ services/backend/Dockerfile | 42 ++ services/backend/Dockerfile.staticfiles | 27 ++ services/backend/README.md | 147 ++++++ services/backend/api/__init__.py | 0 services/backend/api/urls.py | 10 + services/backend/api/v1/__init__.py | 0 services/backend/api/v1/handlers.py | 121 +++++ services/backend/api/v1/ping/__init__.py | 0 services/backend/api/v1/ping/schemas.py | 5 + services/backend/api/v1/ping/views.py | 18 + services/backend/api/v1/router.py | 23 + services/backend/api/v1/schemas.py | 24 + services/backend/apps/__init__.py | 0 services/backend/apps/core/__init__.py | 0 services/backend/apps/core/apps.py | 13 + services/backend/apps/core/models.py | 48 ++ services/backend/config/__init__.py | 3 + services/backend/config/asgi.py | 9 + services/backend/config/celery.py | 10 + services/backend/config/errors.py | 13 + services/backend/config/handlers.py | 43 ++ services/backend/config/settings.py | 587 +++++++++++++++++++++++ services/backend/config/urls.py | 34 ++ services/backend/config/wsgi.py | 9 + services/backend/manage.py | 22 + services/backend/pyproject.toml | 160 ++++++ services/backend/scripts/check | 12 + services/backend/scripts/initdb | 11 + 31 files changed, 1780 insertions(+) create mode 100644 services/backend/.dockerignore create mode 100644 services/backend/.env.template create mode 100644 services/backend/.gitignore create mode 100644 services/backend/Dockerfile create mode 100644 services/backend/Dockerfile.staticfiles create mode 100644 services/backend/README.md create mode 100644 services/backend/api/__init__.py create mode 100644 services/backend/api/urls.py create mode 100644 services/backend/api/v1/__init__.py create mode 100644 services/backend/api/v1/handlers.py create mode 100644 services/backend/api/v1/ping/__init__.py create mode 100644 services/backend/api/v1/ping/schemas.py create mode 100644 services/backend/api/v1/ping/views.py create mode 100644 services/backend/api/v1/router.py create mode 100644 services/backend/api/v1/schemas.py create mode 100644 services/backend/apps/__init__.py create mode 100644 services/backend/apps/core/__init__.py create mode 100644 services/backend/apps/core/apps.py create mode 100644 services/backend/apps/core/models.py create mode 100644 services/backend/config/__init__.py create mode 100644 services/backend/config/asgi.py create mode 100644 services/backend/config/celery.py create mode 100644 services/backend/config/errors.py create mode 100644 services/backend/config/handlers.py create mode 100644 services/backend/config/settings.py create mode 100644 services/backend/config/urls.py create mode 100644 services/backend/config/wsgi.py create mode 100755 services/backend/manage.py create mode 100644 services/backend/pyproject.toml create mode 100755 services/backend/scripts/check create mode 100755 services/backend/scripts/initdb 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 From 09538e903daca2e6036202ef153d8214ccbe115b Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 26 Feb 2025 17:44:27 +0300 Subject: [PATCH 02/20] init: added e2e tests boilerplate --- tests/README.md | 19 +++ tests/e2e/.env.template | 3 + tests/e2e/.gitignore | 170 +++++++++++++++++++++++++ tests/e2e/README.md | 50 ++++++++ tests/e2e/conftest.py | 67 ++++++++++ tests/e2e/pyproject.toml | 109 ++++++++++++++++ tests/e2e/pytest.ini | 3 + tests/e2e/scripts/check | 8 ++ tests/e2e/tests/__init__.py | 0 tests/e2e/tests/test_backend_health.py | 29 +++++ 10 files changed, 458 insertions(+) create mode 100644 tests/README.md create mode 100644 tests/e2e/.env.template create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/pyproject.toml create mode 100644 tests/e2e/pytest.ini create mode 100755 tests/e2e/scripts/check create mode 100644 tests/e2e/tests/__init__.py create mode 100644 tests/e2e/tests/test_backend_health.py diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ff758b6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,19 @@ +# project_name Tests + +There is `unit` and `e2e` tests available, unit tests are placed all around `backend` serivce folder and `e2e` tests placed [here](./e2e/). + +## Running unit tests + +### Backend service + +See [services/backend/README.md](../services/backend/README.md#testing). + +## Unit tests coverage + +### Backend service + +image here + +## Running e2e tests + +See [tests/e2e/README.md](./e2e/README.md). diff --git a/tests/e2e/.env.template b/tests/e2e/.env.template new file mode 100644 index 0000000..014650c --- /dev/null +++ b/tests/e2e/.env.template @@ -0,0 +1,3 @@ +# Below all environment variables and default values + +BACKEND_BASE_URL=http://127.0.0.1:8080 diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..b96e392 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,170 @@ +# 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 diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..467ca99 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,50 @@ +# E2E tests for project_name + +## 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/) +- [Docker compose](https://docs.docker.com/compose/) (latest versions) + +## Warning + +Plese note that containers will use ports from 13241 to 13245 and 8080, so there is must be no listeners on this ports range. + +## Clone the project + +```bash +git clone https://gitlab.prodcontest.ru/2025-final-projects-back/devitq.git +``` + +## Go to the project directory + +```bash +cd devitq/solution/tests/e2e +``` + +## Install dependencies + +```bash +uv sync --no-dev +``` + +## Customize environment (optional) + +```bash +cp .env.template .env +``` + +And setup env vars according to your needs. + +## Run tests + +```bash +uv run pytest . +``` + +## Results + +You will see something like `n passed in Ns` diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..7750ce3 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,67 @@ +import os +import subprocess +import time +from collections.abc import Generator + +import httpx +import pytest +from dotenv import load_dotenv + +load_dotenv() + +BACKEND_BASE_URL = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8080") + + +@pytest.fixture(scope="session", autouse=True) +def docker_compose() -> Generator[None]: + # btw, this is just in case you've forgotten to shut down compose :) + subprocess.run( + executable="docker", + args=[ + "docker", + "compose", + "down", + ], + check=True, + ) + subprocess.run( + executable="docker", + args=[ + "docker", + "compose", + "--project-name", + "project_name", + "up", + "-d", + "--build", + "--force-recreate", + "--remove-orphans", + ], + check=True, + ) + time.sleep(5) + + yield + + subprocess.run( + executable="docker", + args=[ + "docker", + "compose", + "--project-name", + "project_name", + "down", + "-v", + ], + check=True, + ) + + +@pytest.fixture(scope="session") +def client() -> Generator[httpx.Client]: + with httpx.Client(base_url=BACKEND_BASE_URL, timeout=10.0) as client: + yield client + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + items.sort(key=lambda item: "test_health" not in item.nodeid) diff --git a/tests/e2e/pyproject.toml b/tests/e2e/pyproject.toml new file mode 100644 index 0000000..01813d7 --- /dev/null +++ b/tests/e2e/pyproject.toml @@ -0,0 +1,109 @@ +[project] +name = "project_name-e2e-tests" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.10,<3.12" +dependencies = [ + "httpx>=0.28.1", + "pytest>=8.3.4", + "python-dotenv>=1.0.1", +] + +[dependency-groups] +dev = [ + "ruff>=0.9.6", +] + +[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", + "FBT001", + "FBT002", + "N813", + "S101", +] +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 diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini new file mode 100644 index 0000000..7dcb66b --- /dev/null +++ b/tests/e2e/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +log_cli = true +log_level = INFO diff --git a/tests/e2e/scripts/check b/tests/e2e/scripts/check new file mode 100755 index 0000000..7f50734 --- /dev/null +++ b/tests/e2e/scripts/check @@ -0,0 +1,8 @@ +#!/bin/bash + +GREEN='\033[1;32m' +NC='\033[0m' + +uvx ruff format . +uvx ruff check . --fix +printf "${GREEN}Linters/formatters runned${NC}\n" diff --git a/tests/e2e/tests/__init__.py b/tests/e2e/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/tests/test_backend_health.py b/tests/e2e/tests/test_backend_health.py new file mode 100644 index 0000000..e506766 --- /dev/null +++ b/tests/e2e/tests/test_backend_health.py @@ -0,0 +1,29 @@ +import logging +from http import HTTPStatus as status + +from httpx import Client + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def test_healthcheck(client: Client) -> None: + response = client.get("/health?format=json") + assert response.status_code == status.OK + + response_data = response.json() + + unhealthy_services = [ + service + for service, status in response_data.items() + if status != "working" + ] + + for service in unhealthy_services: + logger.error( + "Service %s unhealthy: %s", service, response_data[service] + ) + + assert not unhealthy_services, ( + f"Some services are unhealthy: {', '.join(unhealthy_services)}" + ) From 94fb8984c28bafa493cb9e2f701b42282e21aacd Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 26 Feb 2025 17:46:32 +0300 Subject: [PATCH 03/20] init: added root README.md --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..bedd1d8 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# project_name From 0deb186e613fa60525661663518f5f1d84f62aaf Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 26 Feb 2025 18:22:01 +0300 Subject: [PATCH 04/20] init: added frontend .gitignore --- services/frontend/.gitignore | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 services/frontend/.gitignore diff --git a/services/frontend/.gitignore b/services/frontend/.gitignore new file mode 100644 index 0000000..78add94 --- /dev/null +++ b/services/frontend/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +package-lock.json +pnpm-lock.yaml +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Env files +.env From 4c8998cdfbf4f398fd66647ffda8a24f090a2176 Mon Sep 17 00:00:00 2001 From: moolcoov Date: Wed, 26 Feb 2025 21:32:31 +0300 Subject: [PATCH 05/20] init: add vite setup --- services/frontend/.gitignore | 5 -- services/frontend/README.md | 50 ++++++++++++++++++ services/frontend/bun.lockb | Bin 0 -> 74547 bytes services/frontend/eslint.config.js | 28 ++++++++++ services/frontend/index.html | 13 +++++ services/frontend/package.json | 29 +++++++++++ services/frontend/public/vite.svg | 1 + services/frontend/src/App.css | 42 +++++++++++++++ services/frontend/src/App.tsx | 35 +++++++++++++ services/frontend/src/assets/react.svg | 1 + services/frontend/src/index.css | 68 +++++++++++++++++++++++++ services/frontend/src/main.tsx | 10 ++++ services/frontend/src/vite-env.d.ts | 1 + services/frontend/tsconfig.app.json | 26 ++++++++++ services/frontend/tsconfig.json | 7 +++ services/frontend/tsconfig.node.json | 24 +++++++++ services/frontend/vite.config.ts | 7 +++ 17 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 services/frontend/README.md create mode 100644 services/frontend/bun.lockb create mode 100644 services/frontend/eslint.config.js create mode 100644 services/frontend/index.html create mode 100644 services/frontend/package.json create mode 100644 services/frontend/public/vite.svg create mode 100644 services/frontend/src/App.css create mode 100644 services/frontend/src/App.tsx create mode 100644 services/frontend/src/assets/react.svg create mode 100644 services/frontend/src/index.css create mode 100644 services/frontend/src/main.tsx create mode 100644 services/frontend/src/vite-env.d.ts create mode 100644 services/frontend/tsconfig.app.json create mode 100644 services/frontend/tsconfig.json create mode 100644 services/frontend/tsconfig.node.json create mode 100644 services/frontend/vite.config.ts diff --git a/services/frontend/.gitignore b/services/frontend/.gitignore index 78add94..a547bf3 100644 --- a/services/frontend/.gitignore +++ b/services/frontend/.gitignore @@ -8,8 +8,6 @@ pnpm-debug.log* lerna-debug.log* node_modules -package-lock.json -pnpm-lock.yaml dist dist-ssr *.local @@ -24,6 +22,3 @@ dist-ssr *.njsproj *.sln *.sw? - -# Env files -.env diff --git a/services/frontend/README.md b/services/frontend/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/services/frontend/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/services/frontend/bun.lockb b/services/frontend/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..3c24f3f4548b973920afc75bcce8348a67d379f6 GIT binary patch literal 74547 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 literal 0 HcmV?d00001 diff --git a/services/frontend/eslint.config.js b/services/frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/services/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/services/frontend/index.html b/services/frontend/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/services/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/services/frontend/package.json b/services/frontend/package.json new file mode 100644 index 0000000..e9a3d81 --- /dev/null +++ b/services/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react-swc": "^3.8.0", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.24.1", + "vite": "^6.2.0" + } +} diff --git a/services/frontend/public/vite.svg b/services/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/services/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/frontend/src/App.css b/services/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/services/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx new file mode 100644 index 0000000..3d7ded3 --- /dev/null +++ b/services/frontend/src/App.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> + +

Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/services/frontend/src/assets/react.svg b/services/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/services/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/frontend/src/index.css b/services/frontend/src/index.css new file mode 100644 index 0000000..08a3ac9 --- /dev/null +++ b/services/frontend/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/services/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/services/frontend/src/vite-env.d.ts b/services/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/services/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/services/frontend/tsconfig.app.json b/services/frontend/tsconfig.app.json new file mode 100644 index 0000000..358ca9b --- /dev/null +++ b/services/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/services/frontend/tsconfig.json b/services/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/services/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/services/frontend/tsconfig.node.json b/services/frontend/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/services/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/services/frontend/vite.config.ts b/services/frontend/vite.config.ts new file mode 100644 index 0000000..2328e17 --- /dev/null +++ b/services/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From c1bdc0cc4ed822c0df9328a39146816c809b4e46 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 26 Feb 2025 22:09:47 +0300 Subject: [PATCH 06/20] init: infrastructure --- compose.yaml | 360 ++ infrastructure/.gitignore | 5 + infrastructure/backend/.env.template | 20 + infrastructure/celery-exporter/.env.template | 1 + infrastructure/grafana/grafana.ini | 2079 ++++++++++ .../dashboards/Celery/dashboard.json | 1457 +++++++ .../dashboards/Postgres/dashboard.json | 3686 +++++++++++++++++ .../dashboards/Prometheus/dashboard.json | 3134 ++++++++++++++ .../dashboards/Redis/dashboard.json | 1398 +++++++ .../provisioning/dashboards/providers.yaml | 11 + .../provisioning/datasources/datasources.yaml | 18 + .../provisioning/plugins/plugings.yaml | 4 + infrastructure/grafana/scripts/entrypoint.sh | 9 + infrastructure/minio/.env.template | 3 + infrastructure/pgadmin/.env.template | 2 + infrastructure/pgadmin/servers.json | 18 + .../postgres-exporter/.env.template | 3 + infrastructure/postgres/.env.template | 3 + infrastructure/postgres/postgresql.conf | 858 ++++ infrastructure/prometheus/prometheus.yaml | 33 + infrastructure/redis-exporter/.env.template | 2 + infrastructure/redis/.env.template | 1 + infrastructure/redis/redis.conf | 2333 +++++++++++ 23 files changed, 15438 insertions(+) create mode 100644 compose.yaml create mode 100644 infrastructure/.gitignore create mode 100644 infrastructure/backend/.env.template create mode 100644 infrastructure/celery-exporter/.env.template create mode 100644 infrastructure/grafana/grafana.ini create mode 100644 infrastructure/grafana/provisioning/dashboards/Celery/dashboard.json create mode 100644 infrastructure/grafana/provisioning/dashboards/Postgres/dashboard.json create mode 100644 infrastructure/grafana/provisioning/dashboards/Prometheus/dashboard.json create mode 100644 infrastructure/grafana/provisioning/dashboards/Redis/dashboard.json create mode 100644 infrastructure/grafana/provisioning/dashboards/providers.yaml create mode 100644 infrastructure/grafana/provisioning/datasources/datasources.yaml create mode 100644 infrastructure/grafana/provisioning/plugins/plugings.yaml create mode 100755 infrastructure/grafana/scripts/entrypoint.sh create mode 100644 infrastructure/minio/.env.template create mode 100644 infrastructure/pgadmin/.env.template create mode 100644 infrastructure/pgadmin/servers.json create mode 100644 infrastructure/postgres-exporter/.env.template create mode 100644 infrastructure/postgres/.env.template create mode 100644 infrastructure/postgres/postgresql.conf create mode 100644 infrastructure/prometheus/prometheus.yaml create mode 100644 infrastructure/redis-exporter/.env.template create mode 100644 infrastructure/redis/.env.template create mode 100644 infrastructure/redis/redis.conf diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..716f387 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,360 @@ +name: project_name + +services: + backend: + build: + context: ./services/backend + dockerfile: Dockerfile + depends_on: + backend-initdb: + restart: false + condition: service_completed_successfully + required: true + postgres: + restart: false + condition: service_healthy + required: true + redis: + restart: false + condition: service_healthy + required: true + minio: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + ports: + - name: web + target: 8080 + published: 8080 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + + backend-initdb: + build: + context: ./services/backend + dockerfile: Dockerfile + command: ./scripts/initdb + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + redis: + restart: false + condition: service_healthy + required: true + minio: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + + backend-staticfiles: + build: + context: ./services/backend + dockerfile: Dockerfile.staticfiles + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + healthcheck: + test: ["CMD", "service", "nginx", "status", "||", " exit 1"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 80 + published: 13241 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + + backend-celery-worker: + build: + context: ./services/backend + dockerfile: Dockerfile + command: celery -A config worker -l INFO + depends_on: + redis: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/backend/.env.template + required: true + - path: ./infrastructure/backend/.env + required: false + healthcheck: + test: ["CMD", "celery", "-A", "config", "inspect", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + start_interval: 2s + restart: unless-stopped + + celery-exporter: + image: docker.io/danihodovic/celery-exporter:0.11.1 + depends_on: + redis: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/celery-exporter/.env.template + required: true + - path: ./infrastructure/celery-exporter/.env + required: false + restart: unless-stopped + + redis: + image: docker.io/redis:7-alpine3.21 + command: redis-server /usr/local/etc/redis/redis.conf + configs: + - source: redis_config + target: /usr/local/etc/redis/redis.conf + env_file: + - path: ./infrastructure/redis/.env.template + required: true + - path: ./infrastructure/redis/.env + required: false + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + restart: unless-stopped + shm_size: 4mb + volumes: + - type: volume + source: redis_data + target: /data + + redis-exporter: + image: docker.io/oliver006/redis_exporter:v1.67.0-alpine + depends_on: + redis: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/redis-exporter/.env.template + required: true + - path: ./infrastructure/redis-exporter/.env + required: false + restart: unless-stopped + shm_size: 4mb + + postgres: + image: docker.io/postgres:17-alpine3.21 + configs: + - source: postgres_config + target: /etc/postgresql/postgresql.conf + env_file: + - path: ./infrastructure/postgres/.env.template + required: true + - path: ./infrastructure/postgres/.env + required: false + healthcheck: + test: ["CMD", "pg_isready"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + oom_kill_disable: true + restart: unless-stopped + secrets: + - source: postgres_password + target: /run/secrets/postgres_password + shm_size: 128mb + volumes: + - type: volume + source: postgres_data + target: /var/lib/postgresql/data + + postgres-exporter: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/postgres-exporter/.env.template + required: true + - path: ./infrastructure/postgres-exporter/.env + required: false + restart: unless-stopped + shm_size: 4mb + + pgadmin: + image: docker.io/dpage/pgadmin4:9 + configs: + - source: pgadmin_servers + target: /pgadmin4/servers.json + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/pgadmin/.env.template + required: true + - path: ./infrastructure/pgadmin/.env + required: false + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:80/misc/ping"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 80 + published: 13242 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + secrets: + - source: pgadmin_password + target: /run/secrets/pgadmin_password + shm_size: 4mb + volumes: + - type: volume + source: pgadmin_data + target: /var/lib/pgadmin + + grafana: + image: docker.io/grafana/grafana-oss:11.5.0 + configs: + - source: grafana_config + target: /usr/share/grafana/conf/defaults.ini + entrypoint: ["/etc/grafana/scripts/entrypoint.sh"] + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:3000/api/health"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 3000 + published: 13243 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + shm_size: 4mb + volumes: + - type: volume + source: grafana_data + target: /var/lib/grafana + - type: bind + source: ./infrastructure/grafana/provisioning + target: /etc/grafana/provisioning + - type: bind + source: ./infrastructure/grafana/scripts + target: /etc/grafana/scripts + + minio: + command: server --console-address ":9001" + image: docker.io/minio/minio:RELEASE.2025-02-03T21-03-04Z + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + env_file: + - path: ./infrastructure/minio/.env.template + required: true + - path: ./infrastructure/minio/.env + required: false + ports: + - name: api + target: 9000 + published: 13244 + host_ip: 127.0.0.1 + protocol: tcp + - name: console + target: 9001 + published: 13245 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + volumes: + - type: volume + source: minio_data + target: /data + + prometheus: + image: docker.io/prom/prometheus:v3.1.0 + command: + - "--config.file=/etc/prometheus/prometheus.yaml" + configs: + - source: prometheus_config + target: /etc/prometheus/prometheus.yaml + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:9090/-/healthy"] + interval: 1m30s + timeout: 5s + start_period: 5s + start_interval: 2s + retries: 5 + ports: + - name: web + target: 9090 + published: 13246 + host_ip: 127.0.0.1 + protocol: tcp + restart: unless-stopped + shm_size: 4mb + volumes: + - type: volume + source: prometheus_data + target: /prometheus + +volumes: + redis_data: + postgres_data: + pgadmin_data: + grafana_data: + prometheus_data: + minio_data: + +configs: + redis_config: + file: ./infrastructure/redis/redis.conf + postgres_config: + file: ./infrastructure/postgres/postgresql.conf + pgadmin_servers: + file: ./infrastructure/pgadmin/servers.json + grafana_config: + file: ./infrastructure/grafana/grafana.ini + prometheus_config: + file: ./infrastructure/prometheus/prometheus.yaml + +secrets: + postgres_password: + file: ./infrastructure/postgres/password + pgadmin_password: + file: ./infrastructure/pgadmin/password diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore new file mode 100644 index 0000000..dad66cc --- /dev/null +++ b/infrastructure/.gitignore @@ -0,0 +1,5 @@ +# Custom environment files +.env + +# Password files +password diff --git a/infrastructure/backend/.env.template b/infrastructure/backend/.env.template new file mode 100644 index 0000000..c964cde --- /dev/null +++ b/infrastructure/backend/.env.template @@ -0,0 +1,20 @@ +DJANGO_SECRET_KEY=secretees +DJANGO_DEBUG=False +DJANGO_ALLOWED_HOSTS=* +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=http://localhost:13241/ +REDIS_URI=redis://redis:6379 +DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres + +DJANGO_CREATE_SUPERUSER=True +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_EMAIL=admin@mail.com +DJANGO_SUPERUSER_PASSWORD=admin + +MINIO_ENDPOINT=minio:9000 +MINIO_CUSTOM_ENDPOINT_URL=http://127.0.0.1:13244 +MINIO_ACCESS_KEY=admin +MINIO_SECRET_KEY=password diff --git a/infrastructure/celery-exporter/.env.template b/infrastructure/celery-exporter/.env.template new file mode 100644 index 0000000..0bc4e3e --- /dev/null +++ b/infrastructure/celery-exporter/.env.template @@ -0,0 +1 @@ +CE_BROKER_URL=redis://redis:6379 diff --git a/infrastructure/grafana/grafana.ini b/infrastructure/grafana/grafana.ini new file mode 100644 index 0000000..7f08784 --- /dev/null +++ b/infrastructure/grafana/grafana.ini @@ -0,0 +1,2079 @@ +##################### Grafana Configuration Defaults ##################### +# +# Do not modify this file in grafana installs +# + +# possible values : production, development +app_mode = production + +# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty +instance_name = ${HOSTNAME} + +#################################### Paths ############################### +[paths] +# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) +data = data + +# Temporary files in `data` directory older than given duration will be removed +temp_data_lifetime = 24h + +# Directory where grafana can store logs +logs = data/log + +# Directory where grafana will automatically scan and look for plugins +plugins = data/plugins + +# folder that contains provisioning config files that grafana will apply on startup and while running. +provisioning = conf/provisioning + +#################################### Server ############################## +[server] +# Protocol (http, https, h2, socket) +protocol = http + +# Minimum TLS version allowed. By default, this value is empty. Accepted values are: TLS1.2, TLS1.3. If nothing is set TLS1.2 would be taken +min_tls_version = "" + +# The ip address to bind to, empty will bind to all interfaces +http_addr = + +# The http port to use +http_port = 3000 + +# The public facing domain name used to access grafana from a browser +domain = localhost + +# Redirect to correct domain if host header does not match domain +# Prevents DNS rebinding attacks +enforce_domain = false + +# The full public facing url +root_url = %(protocol)s://%(domain)s:%(http_port)s/ + +# Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons. +serve_from_sub_path = false + +# Log web requests +router_logging = false + +# the path relative working path +static_root_path = public + +# enable gzip +enable_gzip = false + +# https certs & key file +cert_file = +cert_key = +cert_pass = + +# Certificates file watch interval +certs_watch_interval = + +# Unix socket gid +# Changing the gid of a file without privileges requires that the target group is in the group of the process and that the process is the file owner +# It is recommended to set the gid as http server user gid +# Not set when the value is -1 +socket_gid = -1 + +# Unix socket mode +socket_mode = 0660 + +# Unix socket path +socket = /tmp/grafana.sock + +# CDN Url +cdn_url = + +# Sets the maximum time in minutes before timing out read of an incoming request and closing idle connections. +# `0` means there is no timeout for reading the request. +read_timeout = 0 + +# This setting enables you to specify additional headers that the server adds to HTTP(S) responses. +[server.custom_response_headers] +#exampleHeader1 = exampleValue1 +#exampleHeader2 = exampleValue2 + +[environment] +# Sets whether the local file system is available for Grafana to use. Default is true for backward compatibility. +local_file_system_available = true + +#################################### GRPC Server ######################### +[grpc_server] +network = "tcp" +address = "127.0.0.1:10000" +use_tls = false +cert_file = +key_file = +# this will log the request and response for each unary gRPC call +enable_logging = false + +# Maximum size of a message that can be received in bytes. If not set, uses the gRPC default (4MiB). +max_recv_msg_size = + +# Maximum size of a message that can be sent in bytes. If not set, uses the gRPC default (unlimited). +max_send_msg_size = + +#################################### Database ############################ +[database] +# You can configure the database connection by specifying type, host, name, user and password +# as separate properties or as on string using the url property. + +# Either "mysql", "postgres" or "sqlite3", it's your choice +type = sqlite3 +host = 127.0.0.1:3306 +name = grafana +user = root +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" +password = +# Use either URL or the previous fields to configure the database +# Example: mysql://user:secret@host:port/database +url = + +# Max idle conn setting default is 2 +max_idle_conn = 2 + +# Max conn setting default is 0 (mean not set) +max_open_conn = + +# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours) +conn_max_lifetime = 14400 + +# Set to true to log the sql calls and execution times. +log_queries = + +# For "postgres", use either "disable", "require" or "verify-full" +# For "mysql", use either "true", "false", or "skip-verify". +ssl_mode = disable + +# For "postgres", use either "1" to enable or "0" to disable SNI +ssl_sni = + +# Database drivers may support different transaction isolation levels. +# Currently, only "mysql" driver supports isolation levels. +# If the value is empty - driver's default isolation level is applied. +# For "mysql" use "READ-UNCOMMITTED", "READ-COMMITTED", "REPEATABLE-READ" or "SERIALIZABLE". +isolation_level = + +ca_cert_path = +client_key_path = +client_cert_path = +server_cert_name = + +# For "sqlite3" only, path relative to data_path setting +path = grafana.db + +# For "sqlite3" only. cache mode setting used for connecting to the database +cache_mode = private + +# For "sqlite3" only. Enable/disable Write-Ahead Logging, https://sqlite.org/wal.html. Default is false. +wal = false + +# For "mysql" and "postgres". Lock the database for the migrations, default is true. +migration_locking = true + +# For "mysql" and "postgres" only if migrationLocking is set. How many seconds to wait before failing to lock the database for the migrations, default is 0. +locking_attempt_timeout_sec = 0 + +# For "sqlite" only. How many times to retry query in case of database is locked failures. Default is 0 (disabled). +query_retries = 0 + +# For "sqlite" only. How many times to retry transaction in case of database is locked failures. Default is 5. +transaction_retries = 5 + +# Set to true to add metrics and tracing for database queries. +instrument_queries = false + +#################################### Cache server ############################# +[remote_cache] +# Either "redis", "memcached" or "database" default is "database" +type = database + +# cache connectionstring options +# database: will use Grafana primary database. +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`. Only addr is required. ssl may be 'true', 'false', or 'insecure'. +# memcache: 127.0.0.1:11211 +connstr = + +# prefix prepended to all the keys in the remote cache +prefix = + +# This enables encryption of values stored in the remote cache +encryption = + +#################################### Data proxy ########################### +[dataproxy] + +# This enables data proxy logging, default is false +logging = false + +# How long the data proxy waits to read the headers of the response before timing out, default is 30 seconds. +# This setting also applies to core backend HTTP data sources where query requests use an HTTP client with timeout set. +timeout = 30 + +# How long the data proxy waits to establish a TCP connection before timing out, default is 10 seconds. +dialTimeout = 10 + +# How many seconds the data proxy waits before sending a keepalive request. +keep_alive_seconds = 30 + +# How many seconds the data proxy waits for a successful TLS Handshake before timing out. +tls_handshake_timeout_seconds = 10 + +# How many seconds the data proxy will wait for a server's first response headers after +# fully writing the request headers if the request has an "Expect: 100-continue" +# header. A value of 0 will result in the body being sent immediately, without +# waiting for the server to approve. +expect_continue_timeout_seconds = 1 + +# Optionally limits the total number of connections per host, including connections in the dialing, +# active, and idle states. On limit violation, dials will block. +# A value of zero (0) means no limit. +max_conns_per_host = 0 + +# The maximum number of idle connections that Grafana will keep alive. +max_idle_connections = 100 + +# How many seconds the data proxy keeps an idle connection open before timing out. +idle_conn_timeout_seconds = 90 + +# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request. +send_user_header = false + +# Limit the amount of bytes that will be read/accepted from responses of outgoing HTTP requests. +response_limit = 0 + +# Limits the number of rows that Grafana will process from SQL data sources. +row_limit = 1000000 + +# Sets a custom value for the `User-Agent` header for outgoing data proxy requests. If empty, the default value is `Grafana/` (for example `Grafana/9.0.0`). +user_agent = + +#################################### Analytics ########################### +[analytics] +# Server reporting, sends usage counters to stats.grafana.org every 24 hours. +# No ip addresses are being tracked, only simple counters to track +# running instances, dashboard and error counts. It is very helpful to us. +# Change this option to false to disable reporting. +reporting_enabled = true + +# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs +reporting_distributor = grafana-labs + +# Set to false to disable all checks to https://grafana.com +# for new versions of grafana. The check is used +# in some UI views to notify that a grafana update exists. +# This option does not cause any auto updates, nor send any information +# only a GET request to https://grafana.com/api/grafana/versions/stable to get the latest version. +check_for_updates = true + +# Set to false to disable all checks to https://grafana.com +# for new versions of plugins. The check is used +# in some UI views to notify that a plugin update exists. +# This option does not cause any auto updates, nor send any information +# only a GET request to https://grafana.com to get the latest versions. +check_for_plugin_updates = true + +# Google Analytics universal tracking code, only enabled if you specify an id here +google_analytics_ua_id = + +# Google Analytics 4 tracking code, only enabled if you specify an id here +google_analytics_4_id = + +# When Google Analytics 4 Enhanced event measurement is enabled, we will try to avoid sending duplicate events and let Google Analytics 4 detect navigation changes, etc. +google_analytics_4_send_manual_page_views = false + +# Google Tag Manager ID, only enabled if you specify an id here +google_tag_manager_id = + +# Rudderstack write key, enabled only if rudderstack_data_plane_url is also set +rudderstack_write_key = + +# Rudderstack data plane url, enabled only if rudderstack_write_key is also set +rudderstack_data_plane_url = + +# Rudderstack SDK url, optional, only valid if rudderstack_write_key and rudderstack_data_plane_url is also set +rudderstack_sdk_url = + +# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config +rudderstack_config_url = + +# Rudderstack Integrations URL, optional. Only valid if you pass the SDK version 1.1 or higher +rudderstack_integrations_url = + +# Intercom secret, optional, used to hash user_id before passing to Intercom via Rudderstack +intercom_secret = + +# Application Insights connection string. Specify an URL string to enable this feature. +application_insights_connection_string = + +# Optional. Specifies an Application Insights endpoint URL where the endpoint string is wrapped in backticks ``. +application_insights_endpoint_url = + +# Controls if the UI contains any links to user feedback forms +feedback_links_enabled = true + +# Static context that is being added to analytics events +reporting_static_context = + +# Logs interaction events to the browser javascript console, intended for development only +browser_console_reporter = false + +#################################### Security ############################ +[security] +# disable creation of admin user on first start of grafana +disable_initial_admin_creation = false + +# default admin user, created on startup +admin_user = admin + +# default admin password, can be changed before first start of grafana, or in profile settings +admin_password = proooooood + +# default admin email, created on startup +admin_email = admin@localhost + +# used for signing +secret_key = SW2YcwTIb9zpOOhoPsMm + +# current key provider used for envelope encryption, default to static value specified by secret_key +encryption_provider = secretKey.v1 + +# list of configured key providers, space separated (Enterprise only): e.g., awskms.v1 azurekv.v1 +available_encryption_providers = + +# disable gravatar profile images +disable_gravatar = false + +# data source proxy whitelist (ip_or_domain:port separated by spaces) +data_source_proxy_whitelist = + +# disable protection against brute force login attempts +disable_brute_force_login_protection = false + +# max number of failed login attempts before user gets locked +brute_force_login_protection_max_attempts = 5 + +# disable protection against brute force login attempts by IP address +disable_ip_address_login_protection = true + +# set to true if you host Grafana behind HTTPS. default is false. +cookie_secure = false + +# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict", "none" and "disabled" +cookie_samesite = lax + +# set to true if you want to allow browsers to render Grafana in a ,