diff --git a/solution/.dockerignore b/solution/.dockerignore
index d3ae326..c89be8d 100644
--- a/solution/.dockerignore
+++ b/solution/.dockerignore
@@ -1,5 +1,187 @@
-.dockerignore
-Dockerfile
-README.md
-.venv/
+# 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
+.dockerignore
+
+# Git files
+.git
+.gitignore
+
+# Template env file
+.env.template
+
+# Dev utility
+check.sh
+
+# Collected static files
+static
diff --git a/solution/.env.template b/solution/.env.template
new file mode 100644
index 0000000..9c933a3
--- /dev/null
+++ b/solution/.env.template
@@ -0,0 +1,21 @@
+# Change all vars before going to production and remove all comments (!)
+# Below all enviroment variables and default values
+
+DJANGO_SECRET_KEY=very_insecure_key
+DJANGO_DEBUG=True
+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/
+DJANGO_REDIS_HOST=localhost
+DJANGO_REDIS_PORT=6379
+DJANGO_DB_URI=sqlite:///db.sqlite3
+DJANGO_ANTIFRAUD_ADDRESS=localhost:9090
+
+# Notifiers settings (only works with DEBUG=False)
+
+DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=
+DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=
+DJANGO_NOTIFIER_TELEGRAM_THREAD_ID=
diff --git a/solution/.gitignore b/solution/.gitignore
index 808386f..657dc4c 100644
--- a/solution/.gitignore
+++ b/solution/.gitignore
@@ -1,2 +1,173 @@
-.venv/
-__pycache__/
\ No newline at end of file
+# 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/solution/Dockerfile b/solution/Dockerfile
index a19cae9..51ebf13 100644
--- a/solution/Dockerfile
+++ b/solution/Dockerfile
@@ -1,10 +1,62 @@
-FROM python:3.12-alpine3.21
+# Stage 1: Install dependencies
+FROM docker.io/python:3.11-alpine3.20 AS builder
-WORKDIR /usr/src/app
+# Install uv
+COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/
-COPY requirements.txt .
-RUN pip install -r requirements.txt
+# Set the working directory
+WORKDIR /app
+# Setup env vars
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PYTHONOPTIMIZE=2 \
+ UV_COMPILE_BYTECODE=1 \
+ UV_PROJECT_ENVIRONMENT=/opt/venv
+
+# Copy pyproject.toml file
+COPY pyproject.toml .
+
+# Install dependencies
+RUN uv sync --no-dev --no-install-project --no-cache
+
+
+# Stage 2: Serve the application
+FROM docker.io/python:3.11-alpine3.20
+
+# Set the working directory
+WORKDIR /app
+
+# Copy virtual environment from builder
+COPY --from=builder /opt/venv /opt/venv
+
+# Copy application code
COPY . .
-CMD [ "python", "./main.py" ]
+# Create app user and set permissions
+RUN adduser -D -g '' app && chown -R app:app ./
+
+# Run as non-root user
+USER app
+
+# Setup env vars
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PYTHONOPTIMIZE=2 \
+ PATH="/opt/venv/bin:$PATH"
+
+# Expose port
+EXPOSE 8080
+
+# Healthcheck
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health/?format=json || exit 1
+
+# Set env vars for app (sorry for that)
+ENV DJANGO_DEBUG=False \
+ DJANGO_ALLOWED_HOSTS=* \
+ DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=6196898691:AAHtiIgbLAHlELGqO4qmrKoqjWaEJohr9fY \
+ DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=-1002304409222
+
+# Start gunicorn
+CMD gunicorn config.wsgi -b ${SERVER_ADDRESS}
diff --git a/solution/README.md b/solution/README.md
new file mode 100644
index 0000000..208a04d
--- /dev/null
+++ b/solution/README.md
@@ -0,0 +1,109 @@
+# PROD 2 Stage
+
+## 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 https://github.com/Central-University-IT/test-2025-python-devitq
+```
+
+#### Go to the project directory
+
+```bash
+cd test-2025-python-devitq/solution
+```
+
+#### Customize enviroment
+
+```bash
+cp .env.template .env
+```
+
+And setup env vars according to your needs.
+
+#### Install dependencies
+
+##### For dev enviroment
+
+```bash
+uv sync --all-extras
+```
+
+##### For prod enviroment
+
+```bash
+uv sync --no-dev
+```
+
+#### Running
+
+##### In dev mode
+
+Apply migrations:
+
+```bash
+uv run python manage.py migrate
+```
+
+Start project:
+
+```bash
+uv run python manage.py runserver
+```
+
+##### In prod mode
+
+Apply migrations:
+
+```bash
+uv run python manage.py migrate
+```
+
+Start project:
+
+```bash
+uv run gunicorn config.wsgi
+```
+
+## Containerized setup
+
+### Clone the project
+
+```bash
+git clone https://github.com/Central-University-IT/test-2025-python-devitq
+```
+
+### Go to the project directory
+
+```bash
+cd test-2025-python-devitq/solution
+```
+
+### Build docker image
+
+```bash
+docker build -t prod-2-devitq .
+```
+
+### Customize enviroment
+
+Customize enviroment with `docker run` command (or bind .env file to container), for all enviroment vars and default values see [.env.template](./.env.template).
+
+### Run docker image
+
+```bash
+docker run -p 8080:8080 --name prod-2-devitq prod-2-devitq
+```
+
+Backend will be available on localhost:8080.
diff --git a/solution/api/__init__.py b/solution/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/api/urls.py b/solution/api/urls.py
new file mode 100644
index 0000000..6a5e610
--- /dev/null
+++ b/solution/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("v1/", api_v1_router.urls),
+ # Health endpoint
+ path("health", MainView.as_view(), name="health_check_home"),
+]
diff --git a/solution/api/v1/__init__.py b/solution/api/v1/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/api/v1/auth.py b/solution/api/v1/auth.py
new file mode 100644
index 0000000..1e08c86
--- /dev/null
+++ b/solution/api/v1/auth.py
@@ -0,0 +1,10 @@
+from django.http import HttpRequest
+from ninja.security import HttpBearer
+
+
+class BearerAuth(HttpBearer):
+ def authenticate(self, request: HttpRequest, token: str) -> str | None:
+ if token == "will implement later":
+ return token
+
+ return None
diff --git a/solution/api/v1/handlers.py b/solution/api/v1/handlers.py
new file mode 100644
index 0000000..40faccb
--- /dev/null
+++ b/solution/api/v1/handlers.py
@@ -0,0 +1,77 @@
+import logging
+from http import HTTPStatus as status
+
+import django.core.exceptions
+import django.http
+from django.http import HttpRequest, HttpResponse
+from ninja import NinjaAPI, errors
+
+logger = logging.getLogger("django")
+
+
+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.UNPROCESSABLE_ENTITY,
+ )
+
+
+def handle_authentication_error(
+ request: HttpRequest, exc: errors.AuthenticationError, router: NinjaAPI
+) -> HttpResponse:
+ return router.create_response(
+ request,
+ {"detail": status.UNAUTHORIZED.phrase},
+ status=status.UNAUTHORIZED,
+ )
+
+
+def handle_validation_error(
+ request: HttpRequest, exc: errors.ValidationError, router: NinjaAPI
+) -> HttpResponse:
+ return router.create_response(
+ request,
+ {"detail": exc.errors},
+ status=status.UNPROCESSABLE_ENTITY,
+ )
+
+
+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_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 = [
+ (django.core.exceptions.ValidationError, handle_django_validation_error),
+ (errors.AuthenticationError, handle_authentication_error),
+ (errors.ValidationError, handle_validation_error),
+ (django.http.Http404, handle_not_found_error),
+ (Exception, handle_unknown_exception),
+]
diff --git a/solution/api/v1/ping/__init__.py b/solution/api/v1/ping/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/api/v1/ping/apps.py b/solution/api/v1/ping/apps.py
new file mode 100644
index 0000000..13c0d30
--- /dev/null
+++ b/solution/api/v1/ping/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class PingConfig(AppConfig):
+ name = "api.v1.ping"
+ label = "api_v1_ping"
diff --git a/solution/api/v1/ping/schemas.py b/solution/api/v1/ping/schemas.py
new file mode 100644
index 0000000..b507b0d
--- /dev/null
+++ b/solution/api/v1/ping/schemas.py
@@ -0,0 +1,8 @@
+from ninja import Schema
+
+
+class PingOut(Schema):
+ message_from_basement: str
+
+
+__all__ = ["PingOut"]
diff --git a/solution/api/v1/ping/views.py b/solution/api/v1/ping/views.py
new file mode 100644
index 0000000..01847ed
--- /dev/null
+++ b/solution/api/v1/ping/views.py
@@ -0,0 +1,21 @@
+from http import HTTPStatus as status
+
+from django.conf import settings
+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},
+ summary="Ping server",
+)
+def index(
+ request: HttpRequest,
+) -> schemas.PingOut:
+ settings.LOGGER.info("кто-то стучится в пинг")
+ return schemas.PingOut(message_from_basement="АЛЕКСАНДР ШАХОВ Я ВАШ ФОНАТ")
diff --git a/solution/api/v1/router.py b/solution/api/v1/router.py
new file mode 100644
index 0000000..1da4e50
--- /dev/null
+++ b/solution/api/v1/router.py
@@ -0,0 +1,28 @@
+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="Promocode API",
+ version="1",
+ description="API docs for Promocode",
+ openapi_url="/docs/openapi.json",
+ # csrf=True, noqa: ERA001
+)
+
+
+# Register application's routers
+
+router.add_router(
+ "ping",
+ ping_router,
+)
+
+
+# Register exception handlers
+
+for exception, handler in handlers.exception_handlers:
+ router.add_exception_handler(exception, partial(handler, router=router))
diff --git a/solution/api/v1/schemas.py b/solution/api/v1/schemas.py
new file mode 100644
index 0000000..36a65e5
--- /dev/null
+++ b/solution/api/v1/schemas.py
@@ -0,0 +1,11 @@
+from http import HTTPStatus as status
+
+from ninja import Schema
+
+
+class UnauthorizedError(Schema):
+ detail: str = status.UNAUTHORIZED.phrase
+
+
+class NotFoundError(Schema):
+ detail: str = status.NOT_FOUND.phrase
diff --git a/solution/apps/__init__.py b/solution/apps/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/check.sh b/solution/check.sh
new file mode 100644
index 0000000..80e661c
--- /dev/null
+++ b/solution/check.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+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/solution/config/__init__.py b/solution/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/config/asgi.py b/solution/config/asgi.py
new file mode 100644
index 0000000..d0bfe8f
--- /dev/null
+++ b/solution/config/asgi.py
@@ -0,0 +1,9 @@
+"""ASGI config for Promocode."""
+
+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/solution/config/handlers.py b/solution/config/handlers.py
new file mode 100644
index 0000000..aa41f72
--- /dev/null
+++ b/solution/config/handlers.py
@@ -0,0 +1,39 @@
+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/solution/config/notifiers/__init__.py b/solution/config/notifiers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/config/notifiers/telegram.py b/solution/config/notifiers/telegram.py
new file mode 100644
index 0000000..fd3e3b0
--- /dev/null
+++ b/solution/config/notifiers/telegram.py
@@ -0,0 +1,129 @@
+import datetime
+import logging
+import time
+import traceback
+from concurrent.futures import ThreadPoolExecutor
+
+import httpx
+from django.utils.timezone import get_current_timezone
+
+TELEGRAM_LOG_HANDLER = logging.getLogger("telegram_log_handler")
+
+LEVEL_EMOJIS = {
+ "DEBUG": "🐞",
+ "INFO": "ℹ️",
+ "WARNING": "⚠️",
+ "ERROR": "❌",
+ "CRITICAL": "🚨",
+}
+
+
+class LoggingHandler(logging.Handler):
+ _executor = ThreadPoolExecutor(max_workers=5)
+
+ def __init__(
+ self,
+ token: str,
+ chat_id: int,
+ thread_id: int | None = None,
+ retries: int | None = 3,
+ delay: int | None = 2,
+ timeout: int | None = 5,
+ ) -> None:
+ super().__init__()
+
+ self.token = token
+ self.chat_id = chat_id
+ self.thread_id = thread_id
+ self.retries = retries
+ self.delay = delay
+ self.timeout = timeout
+ self.api_url = f"https://api.telegram.org/bot{self.token}/sendMessage"
+
+ self.template = (
+ "{levelname}\n"
+ "\tGuid: {correlation_id}\n"
+ "\tTimestamp: {asctime}\n"
+ "\tLogger: {name}\n"
+ "\tFile: {pathname} "
+ "(Line: {lineno})\n\n"
+ '
\n' + ) + + def emit(self, record: logging.LogRecord) -> None: + try: + formatted_record = self.format(record) + self._executor.submit(self._send_message, formatted_record) + except Exception as e: # noqa: BLE001 + self.handleError(record) + TELEGRAM_LOG_HANDLER.exception(e) + + def _send_message(self, formatted_record: str) -> None: + payload = { + "chat_id": self.chat_id, + "text": formatted_record, + "parse_mode": "HTML", + } + if self.thread_id: + payload["reply_to_message_id"] = self.thread_id + + for attempt in range(1, self.retries + 1): + response = httpx.post( + self.api_url, data=payload, timeout=self.timeout + ) + if response.status_code != httpx.codes.OK: + if attempt == self.retries: + TELEGRAM_LOG_HANDLER.exception( + "Failed to send to Telegram after %d attempts: %s", + self.retries, + response.text, + ) + else: + time.sleep(self.delay) + else: + return + + def format(self, record: logging.LogRecord) -> str: + try: + asctime = datetime.datetime.fromtimestamp( + record.created, tz=get_current_timezone() + ).strftime("%Y-%m-%d %H:%M:%S %Z") + level_emoji = LEVEL_EMOJIS.get(record.levelname, "") + + formatted_message = self.template.format( + levelname=f"{level_emoji} {record.levelname}", + correlation_id=getattr(record, "correlation_id", "N/A"), + asctime=asctime, + name=record.name, + pathname=record.pathname, + lineno=record.lineno, + message=record.getMessage(), + ) + + if record.exc_info: + formatted_message += self._format_exception(record.exc_info) + + formatted_message += ( + f"\n#{record.levelname.lower()} " + f"#{record.name.replace('.', '_')}" + ) + if hasattr(record, "correlation_id"): + formatted_message += f" #{record.correlation_id}" + except Exception as format_error: # noqa: BLE001 + TELEGRAM_LOG_HANDLER.exception( + "Error formatting log record: %s", format_error + ) + return f"Error formatting log record: {format_error}" + else: + return formatted_message + + @staticmethod + def _format_exception(exc_info: Exception) -> str: + exc_text = "".join(traceback.format_exception(*exc_info)) + return ( + f"\n
{exc_text}"
+ )
+
+ @classmethod
+ def shutdown_executor(cls) -> None:
+ cls._executor.shutdown(wait=True)
diff --git a/solution/config/settings.py b/solution/config/settings.py
new file mode 100644
index 0000000..1d74dfb
--- /dev/null
+++ b/solution/config/settings.py
@@ -0,0 +1,544 @@
+"""Django settings for Promocode."""
+
+import logging
+from pathlib import Path
+
+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")
+
+
+# Common settings
+
+DEBUG = env("DJANGO_DEBUG", default=True)
+
+ALLOWED_HOSTS = env(
+ "DJANGO_ALLOWED_HOSTS",
+ list,
+ default=["localhost", "127.0.0.1"],
+)
+
+
+# Integrations
+
+ANTIFRAUD_ADDRESS = env("ANTIFRAUD_ADDRESS", default="localhost:9090")
+
+
+# Caching
+
+REDIS_URI = (
+ "redis://"
+ f"{env('REDIS_HOST', default='localhost')}"
+ ":"
+ f"{env('REDIS_PORT', default='6379')}"
+)
+
+CACHES = {
+ "default": {
+ "BACKEND": "django.core.cache.backends.redis.RedisCache",
+ "LOCATION": REDIS_URI,
+ "TIMEOUT": None,
+ "KEY_PREFIX": "django",
+ }
+}
+
+
+# Database
+
+DB_URI = env.db_url("POSTGRES_CONN", 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 = []
+
+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
+
+
+# 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 = []
+
+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"
+
+
+# Notifiers
+
+# Telegram
+
+NOTIFIER_TELEGRAM_BOT_TOKEN = env(
+ "DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN", default=None
+)
+
+NOTIFIER_TELEGRAM_CHAT_ID = env(
+ "DJANGO_NOTIFIER_TELEGRAM_CHAT_ID", default=None
+)
+
+NOTIFIER_TELEGRAM_THREAD_ID = env(
+ "DJANGO_NOTIFIER_TELEGRAM_THREAD_ID", default=None
+)
+
+
+# Logging
+
+LOGGER_NAME = "promocode"
+
+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,
+ },
+}
+
+if NOTIFIER_TELEGRAM_BOT_TOKEN and NOTIFIER_TELEGRAM_CHAT_ID:
+ LOGGING_HANDLERS["telegram"] = {
+ "class": "config.notifiers.telegram.LoggingHandler",
+ "level": "INFO",
+ "filters": ["require_debug_false"],
+ "token": NOTIFIER_TELEGRAM_BOT_TOKEN,
+ "chat_id": NOTIFIER_TELEGRAM_CHAT_ID,
+ "thread_id": NOTIFIER_TELEGRAM_THREAD_ID,
+ "retries": 5,
+ "delay": 2,
+ "timeout": 5,
+ }
+ LOGGING_LOGGERS["django"]["handlers"].append("telegram")
+ LOGGING_LOGGERS["django.request"]["handlers"].append("telegram")
+ LOGGING_LOGGERS["health-check"]["handlers"].append("telegram")
+ LOGGING_LOGGERS[LOGGER_NAME]["handlers"].append("telegram")
+
+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 = {}
+
+FIXTURE_DIRS = []
+
+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",
+ # Third-party apps
+ "corsheaders",
+ "django_extensions",
+ "django_guid",
+ "ninja",
+ # Internal apps
+ # API v1 apps
+ "api.v1.ping",
+]
+
+# 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("RANDOM_SECRET", default="very_insecure_key")
+
+SECRET_KEY_FALLBACKS = []
+
+
+# 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 = []
+
+TEST_RUNNER = "django.test.runner.DiscoverRunner"
+
+
+# URLs
+
+ROOT_URLCONF = "config.urls"
+
+
+# debug-toolbar
+
+DEBUG_TOOLBAR_CONFIG = {"SHOW_COLLAPSED": True, "UPDATE_ON_FETCH": True}
+
+if DEBUG:
+ INSTALLED_APPS.append("debug_toolbar")
+ MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
diff --git a/solution/config/urls.py b/solution/config/urls.py
new file mode 100644
index 0000000..7de54c6
--- /dev/null
+++ b/solution/config/urls.py
@@ -0,0 +1,42 @@
+"""URL configuration for Promocode backend."""
+
+from django.conf import settings
+from django.contrib import admin
+from django.urls import include, path
+
+from config import handlers
+
+# Custom settings for django admin
+
+admin.site.site_title = "Promocode"
+admin.site.site_header = "Promocode"
+admin.site.index_title = "Promocode"
+
+
+# Basic urlpatterns
+
+urlpatterns = [
+ # Admin urls
+ path("admin/", admin.site.urls),
+ # API urls
+ path("api/", include("api.urls")),
+]
+
+
+# Add debug-toolbar urls
+
+if settings.DEBUG:
+ from debug_toolbar.toolbar import debug_toolbar_urls
+
+ urlpatterns += debug_toolbar_urls()
+
+
+# Register custom error handlers
+
+handler400 = handlers.handler400
+
+handler403 = handlers.handler403
+
+handler404 = handlers.handler404
+
+handler500 = handlers.handler500
diff --git a/solution/config/wsgi.py b/solution/config/wsgi.py
new file mode 100644
index 0000000..bee638d
--- /dev/null
+++ b/solution/config/wsgi.py
@@ -0,0 +1,9 @@
+"""WSGI config for Promocode."""
+
+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/solution/main.py b/solution/main.py
deleted file mode 100644
index 0631399..0000000
--- a/solution/main.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import os
-from fastapi import FastAPI
-import uvicorn
-
-app = FastAPI()
-
-@app.get("/api/ping")
-def send():
- return {"status": "ok"}
-
-if __name__ == "__main__":
- server_address = os.getenv("SERVER_ADDRESS", "0.0.0.0:8080")
- host, port = server_address.split(":")
- uvicorn.run(app, host=host, port=int(port))
diff --git a/solution/manage.py b/solution/manage.py
new file mode 100755
index 0000000..f821d3a
--- /dev/null
+++ b/solution/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/solution/pyproject.toml b/solution/pyproject.toml
new file mode 100644
index 0000000..4ff701a
--- /dev/null
+++ b/solution/pyproject.toml
@@ -0,0 +1,110 @@
+[project]
+name = "prod-2-stage"
+version = "0.1.0"
+readme = "README.md"
+requires-python = ">=3.10,<3.12"
+dependencies = [
+ "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-ninja>=1.3.0",
+ "gunicorn>=23.0.0",
+ "httpx>=0.28.1",
+ "psycopg2-binary>=2.9.10",
+ "python-json-logger>=3.2.1",
+ "redis>=5.2.1",
+]
+
+[dependency-groups]
+dev = [
+ "django-debug-toolbar>=4.4.6",
+ "ruff>=0.9.1",
+]
+
+[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", "COM812", "D", "ISC001", "PT009" ,"N813"]
+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/solution/requirements.txt b/solution/requirements.txt
deleted file mode 100644
index 97dc7cd..0000000
--- a/solution/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-fastapi
-uvicorn